From f43ef8a7a1d7e69fe5a00659f5e560a05dc6c77c Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 29 Jul 2026 02:18:30 -0700 Subject: [PATCH] fix(did): re-mint stale-domain DIDs from the client, where the key lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #215 tried to re-mint server-side via didCreation:createDIDWebVH. That could never work, and running it on prod proved it: Turnkey error 3: organization mismatch: request is targeting organization (20ed9d43-...), but voters are in organization (66cb8d23-...) createDIDWebVH had no callers — it was dead code. The live path is useAuth.tsx -> createUserWebVHDid, which mints in the BROWSER using an Ed25519 key in localStorage (schema.ts:48 says so: "created client-side"). The server has never held that key, so it cannot mint a replacement the user would still control. Removed the unusable action; the tested applyRemint rewrite is kept as-is, since it does not care where the new DID came from. The mint now happens in the browser, reusing the same localStorage key so the new DID has the same controller. Authorization: applyRemint reassigns every row from one DID to another, so exposing it to clients naively would be an account-takeover primitive — and the app's other mutations do trust client-supplied DIDs. remintDid:remintUserDid instead RESOLVES the submitted log (resolveDIDFromLog verifies each entry's Data Integrity proof, so it cannot be forged without the key), requires its update keys to intersect the stored log's keys for the old DID, and requires the new DID to land on this deployment's WEBVH_DOMAIN. Any failure throws before a row is touched. Also fixes the two bugs that produced the broken share link: - domainFromDid returned parts[3] undecoded, so a host with a port yielded `https://localhost%3A5173/...` — an invalid host. Now decoded, and localhost gets http since it has no certificate. - currentWebvhDomain checks isNativePlatform BEFORE VITE_WEBVH_DOMAIN. The env var is baked at build time, so `bun run cap:build` (which reads .env.local) used to stamp a dev host into real native DIDs, overriding the boop.ad branch. useDidDomainRemint is explicitly temporary — a repair pass, not a compatibility layer — and never blocks login: a failure leaves the old DID working as an identifier, just unresolvable, and retries on the next load. Co-Authored-By: Claude Opus 5 (1M context) --- convex/_generated/api.d.ts | 4 +- convex/migrations/remintUserDid.ts | 114 ------------------------ convex/migrations/remintUserDidDb.ts | 47 ++++++++++ convex/remintDid.ts | 109 +++++++++++++++++++++++ scripts/webvh-domain.test.mjs | 128 +++++++++++++++++++++++++++ src/hooks/useAuth.tsx | 4 + src/hooks/useDidDomainRemint.ts | 64 ++++++++++++++ src/lib/webvh.ts | 43 +++++++-- 8 files changed, 392 insertions(+), 121 deletions(-) delete mode 100644 convex/migrations/remintUserDid.ts create mode 100644 convex/remintDid.ts create mode 100644 scripts/webvh-domain.test.mjs create mode 100644 src/hooks/useDidDomainRemint.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 687993b..73fff4c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -56,7 +56,6 @@ import type * as listsHttp from "../listsHttp.js"; import type * as migrations_bucketBackfill from "../migrations/bucketBackfill.js"; import type * as migrations_celAssetDids from "../migrations/celAssetDids.js"; import type * as migrations_celAssetDidsDb from "../migrations/celAssetDidsDb.js"; -import type * as migrations_remintUserDid from "../migrations/remintUserDid.js"; import type * as migrations_remintUserDidDb from "../migrations/remintUserDidDb.js"; import type * as notificationActions from "../notificationActions.js"; import type * as notifications from "../notifications.js"; @@ -66,6 +65,7 @@ import type * as presenceHttp from "../presenceHttp.js"; import type * as publication from "../publication.js"; import type * as rateLimits from "../rateLimits.js"; import type * as referrals from "../referrals.js"; +import type * as remintDid from "../remintDid.js"; import type * as siteActions from "../siteActions.js"; import type * as siteAssets from "../siteAssets.js"; import type * as siteInternals from "../siteInternals.js"; @@ -133,7 +133,6 @@ declare const fullApi: ApiFromModules<{ "migrations/bucketBackfill": typeof migrations_bucketBackfill; "migrations/celAssetDids": typeof migrations_celAssetDids; "migrations/celAssetDidsDb": typeof migrations_celAssetDidsDb; - "migrations/remintUserDid": typeof migrations_remintUserDid; "migrations/remintUserDidDb": typeof migrations_remintUserDidDb; notificationActions: typeof notificationActions; notifications: typeof notifications; @@ -143,6 +142,7 @@ declare const fullApi: ApiFromModules<{ publication: typeof publication; rateLimits: typeof rateLimits; referrals: typeof referrals; + remintDid: typeof remintDid; siteActions: typeof siteActions; siteAssets: typeof siteAssets; siteInternals: typeof siteInternals; diff --git a/convex/migrations/remintUserDid.ts b/convex/migrations/remintUserDid.ts deleted file mode 100644 index a932ae3..0000000 --- a/convex/migrations/remintUserDid.ts +++ /dev/null @@ -1,114 +0,0 @@ -"use node"; - -/** - * Re-mint a user's did:webvh onto the current WEBVH_DOMAIN. - * - * Why a re-mint and not an update: did:webvh puts the domain inside the - * identifier, and these DIDs are created `portable: false` (didCreation.ts:57), - * so per spec the domain cannot move. A DID minted while the app was served from - * an old domain names that domain forever — and since a did:webvh resolves by - * fetching did.jsonl from its own domain, once that domain stops serving, the - * DID is unresolvable and every list published under it fails to verify. - * - * The new DID reuses the SAME Turnkey key and the SAME slug - * (`user-{subOrgId:16}`), so only the domain and the derived SCID change. The - * user keeps their keys; only the identifier moves. - * - * Dry run first — it only counts rows: - * npx convex run --prod migrations/remintUserDid:preview '{"email":"..."}' - * - * Then: - * npx convex run --prod migrations/remintUserDid:remintByEmail '{"email":"..."}' - * - * Idempotent: a user whose DID already sits on WEBVH_DOMAIN is skipped. - */ - -import { v } from "convex/values"; -import { internalAction } from "../_generated/server"; -import { internal } from "../_generated/api"; -import type { Id } from "../_generated/dataModel"; - -type Candidate = { - _id: Id<"users">; - did: string; - email?: string; - turnkeySubOrgId?: string; -}; - -function requireDomain(): string { - const domain = process.env.WEBVH_DOMAIN; - if (!domain) throw new Error("WEBVH_DOMAIN is not set on this deployment"); - return domain; -} - -function domainOf(did: string): string { - const parts = did.split(":"); - if (parts.length < 5) throw new Error(`Not a did:webvh with a path: ${did}`); - return decodeURIComponent(parts[3]); -} - -/** Counts what a re-mint would touch. Changes nothing. */ -export const preview = internalAction({ - args: { email: v.string() }, - handler: async (ctx, args): Promise => { - const domain = requireDomain(); - const user: Candidate | null = await ctx.runQuery( - internal.migrations.remintUserDidDb.findUserByEmail, - { email: args.email } - ); - if (!user) throw new Error(`No user with email ${args.email}`); - - const current = domainOf(user.did); - const counts = await ctx.runQuery(internal.migrations.remintUserDidDb.previewRemint, { - oldDid: user.did, - }); - - return { - email: args.email, - currentDid: user.did, - currentDomain: current, - targetDomain: domain, - needsRemint: current !== domain, - rowsAffected: counts, - }; - }, -}); - -export const remintByEmail = internalAction({ - args: { email: v.string() }, - handler: async ( - ctx, - args - ): Promise<{ oldDid: string; newDid: string; rewritten: number } | { skipped: string }> => { - const domain = requireDomain(); - const user: Candidate | null = await ctx.runQuery( - internal.migrations.remintUserDidDb.findUserByEmail, - { email: args.email } - ); - if (!user) throw new Error(`No user with email ${args.email}`); - if (!user.turnkeySubOrgId) { - throw new Error(`User ${args.email} has no Turnkey sub-org; cannot re-mint`); - } - if (domainOf(user.did) === domain) { - return { skipped: `${user.did} is already on ${domain}` }; - } - - // Same key, same slug — only the domain (and derived SCID) change. - const { did: newDid }: { did: string } = await ctx.runAction( - internal.didCreation.createDIDWebVH, - { subOrgId: user.turnkeySubOrgId, email: args.email } - ); - - if (newDid === user.did) { - return { skipped: `re-mint produced the same DID (${newDid})` }; - } - - const { rewritten }: { rewritten: number; skipped: boolean } = await ctx.runMutation( - internal.migrations.remintUserDidDb.applyRemint, - { userId: user._id, oldDid: user.did, newDid } - ); - - console.log(`[remintUserDid] ${user.did} -> ${newDid} (${rewritten} rows rewritten)`); - return { oldDid: user.did, newDid, rewritten }; - }, -}); diff --git a/convex/migrations/remintUserDidDb.ts b/convex/migrations/remintUserDidDb.ts index 2fc8efd..0792e07 100644 --- a/convex/migrations/remintUserDidDb.ts +++ b/convex/migrations/remintUserDidDb.ts @@ -116,6 +116,53 @@ export const findUserByEmail = internalQuery({ }, }); +export const findUserByDid = internalQuery({ + args: { did: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("by_did", (q) => q.eq("did", args.did)) + .first(); + return user ? { _id: user._id, did: user.did!, email: user.email } : null; + }, +}); + +/** The stored did.jsonl for a DID — the authorization anchor for a re-mint. */ +export const getDidLogFor = internalQuery({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + return row ? { log: row.log, path: row.path } : null; + }, +}); + +export const storeDidLog = internalMutation({ + args: { userDid: v.string(), path: v.string(), log: v.string() }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + if (existing) { + await ctx.db.patch(existing._id, { + userDid: args.userDid, + log: args.log, + updatedAt: Date.now(), + }); + return; + } + await ctx.db.insert("didLogs", { + userDid: args.userDid, + path: args.path, + log: args.log, + updatedAt: Date.now(), + }); + }, +}); + /** Users whose DID sits on a domain other than the one we mint on today. */ export const listUsersOnDomain = internalQuery({ args: { domain: v.string() }, diff --git a/convex/remintDid.ts b/convex/remintDid.ts new file mode 100644 index 0000000..6378f64 --- /dev/null +++ b/convex/remintDid.ts @@ -0,0 +1,109 @@ +"use node"; + +/** + * Client-driven re-mint of a user's did:webvh onto the current WEBVH_DOMAIN. + * + * The mint has to happen in the browser: a user DID is signed by an Ed25519 key + * held in that browser's localStorage (see getOrCreateKeyPair in lib/webvh.ts), + * and the server has never held it. The server's job is to authorize the swap + * and rewrite the rows. + * + * AUTHORIZATION — this matters, because applyRemint reassigns every row owned by + * one DID to another, which is an account-takeover primitive if it is callable + * with arbitrary arguments. The app's other mutations trust client-supplied + * DIDs; this one must not. Instead: + * + * 1. The submitted log is RESOLVED, not merely parsed. resolveDIDFromLog + * verifies each entry's Data Integrity proof, so a log cannot be forged + * without the signing key. + * 2. Its update keys must intersect the stored log's update keys for oldDid. + * Re-mint reuses the same localStorage key, so the honest client always + * matches, while an attacker would need the victim's private key. + * 3. The new DID must land on this deployment's WEBVH_DOMAIN, so this can only + * ever move an identity onto the canonical domain. + * + * Any failure throws before a single row is touched. + */ + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { resolveDIDFromLog } from "didwebvh-ts"; +import type { DIDLog } from "didwebvh-ts"; + +function parseLog(jsonl: string): DIDLog { + const entries = jsonl + .trim() + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + if (entries.length === 0) throw new Error("DID log is empty"); + return entries as DIDLog; +} + +function domainOf(did: string): string | null { + const parts = did.split(":"); + if (parts.length < 5 || parts[1] !== "webvh") return null; + try { + return decodeURIComponent(parts[3]); + } catch { + return parts[3]; + } +} + +export const remintUserDid = action({ + args: { + oldDid: v.string(), + newDidLog: v.string(), + path: v.string(), + }, + handler: async ( + ctx, + args + ): Promise<{ newDid: string; rewritten: number } | { skipped: string }> => { + const targetDomain = process.env.WEBVH_DOMAIN; + if (!targetDomain) throw new Error("WEBVH_DOMAIN is not set on this deployment"); + + // (1) Resolve — this verifies every entry's proof, not just its shape. + const { did: newDid, meta: newMeta } = await resolveDIDFromLog(parseLog(args.newDidLog)); + + if (newDid === args.oldDid) return { skipped: "already on the current DID" }; + + // (3) Only ever move onto this deployment's canonical domain. + const newDomain = domainOf(newDid); + if (newDomain !== targetDomain) { + throw new Error(`Re-mint must target ${targetDomain}, got ${newDomain ?? "unknown"}`); + } + + const user = await ctx.runQuery(internal.migrations.remintUserDidDb.findUserByDid, { + did: args.oldDid, + }); + if (!user) throw new Error("No user holds that DID"); + + // (2) Prove the caller controls the key that controls the OLD DID. + const oldRecord = await ctx.runQuery(internal.migrations.remintUserDidDb.getDidLogFor, { + userDid: args.oldDid, + }); + if (!oldRecord) throw new Error("No stored DID log for the current DID; cannot authorize"); + + const { meta: oldMeta } = await resolveDIDFromLog(parseLog(oldRecord.log)); + const shared = newMeta.updateKeys.filter((k) => oldMeta.updateKeys.includes(k)); + if (shared.length === 0) { + throw new Error("New DID is not controlled by the current DID's update key"); + } + + const { rewritten }: { rewritten: number; skipped: boolean } = await ctx.runMutation( + internal.migrations.remintUserDidDb.applyRemint, + { userId: user._id, oldDid: args.oldDid, newDid } + ); + + await ctx.runMutation(internal.migrations.remintUserDidDb.storeDidLog, { + userDid: newDid, + path: args.path, + log: args.newDidLog, + }); + + console.log(`[remintDid] ${args.oldDid} -> ${newDid} (${rewritten} rows)`); + return { newDid, rewritten }; + }, +}); diff --git a/scripts/webvh-domain.test.mjs b/scripts/webvh-domain.test.mjs new file mode 100644 index 0000000..b4e41fe --- /dev/null +++ b/scripts/webvh-domain.test.mjs @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/webvh-domain-test"; +let scenario = 0; + +/** + * Builds each scenario to its OWN file: the `define` values differ per scenario, + * and a shared outfile plus a same-millisecond cache-buster let bun reuse a + * stale module. + */ +async function loadModule({ native, envDomain, host }) { + const id = ++scenario; + if (id === 1) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + } + await build({ + entryPoints: ["src/lib/webvh.ts"], + outfile: `${outdir}/webvh-${id}.mjs`, + bundle: true, + // node, not neutral: didwebvh-ts's esm build imports node:module. The + // functions under test are pure string handling, so the platform is moot. + platform: "node", + format: "esm", + target: "es2022", + define: { + // JSON.stringify(undefined) is not a string; esbuild needs the literal. + "import.meta.env.VITE_WEBVH_DOMAIN": + envDomain === undefined ? "undefined" : JSON.stringify(envDomain), + }, + // Capacitor's platform check is the thing under test; stub it per scenario. + plugins: [ + { + name: "stub-capacitor", + setup(b) { + b.onResolve({ filter: /^@capacitor\/core$/ }, () => ({ + path: "capacitor-stub", + namespace: "stub", + })); + b.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ + contents: `export const Capacitor = { isNativePlatform: () => ${native} };`, + loader: "js", + })); + }, + }, + ], + }); + const mod = await import(pathToFileURL(`${process.cwd()}/${outdir}/webvh-${id}.mjs`).href); + // `window` is read at call time, so hand back a wrapper that installs it only + // for the duration of the call. bun shares globals across test files, and a + // leaked `window` makes unrelated modules believe they are in a browser. + return new Proxy(mod, { + get(target, prop) { + const value = target[prop]; + if (typeof value !== "function") return value; + return (...fnArgs) => { + const had = "window" in globalThis; + const previous = globalThis.window; + globalThis.window = { location: { host } }; + try { + return value(...fnArgs); + } finally { + if (had) globalThis.window = previous; + else delete globalThis.window; + } + }; + }, + }); +} + +test("native builds ignore a baked-in dev VITE_WEBVH_DOMAIN", async () => { + // The regression: `bun run cap:build` reads .env.local, so a dev host used to + // get stamped into real native DIDs. + const mod = await loadModule({ + native: true, + envDomain: "localhost:5173", + host: "localhost", + }); + assert.equal(mod.currentWebvhDomain(), "boop.ad"); +}); + +test("web builds still honour VITE_WEBVH_DOMAIN, then fall back to the host", async () => { + const withEnv = await loadModule({ + native: false, + envDomain: "localhost:5173", + host: "boop.ad", + }); + assert.equal(withEnv.currentWebvhDomain(), "localhost:5173"); + + const withoutEnv = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + assert.equal(withoutEnv.currentWebvhDomain(), "boop.ad"); +}); + +test("domainFromDid decodes a percent-encoded host", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + assert.equal(mod.domainFromDid("did:webvh:QmS:localhost%3A5173:user-a"), "localhost:5173"); + assert.equal(mod.domainFromDid("did:webvh:QmS:boop.ad:user-a"), "boop.ad"); +}); + +test("buildListResourceUrl produces an openable URL for both hosts", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + + assert.equal( + mod.buildListResourceUrl("did:webvh:QmS:boop.ad:user-a", "l1"), + "https://boop.ad/user-a/resources/list-l1" + ); + // Was `https://localhost%3A5173/...` — an invalid host and an unusable scheme. + assert.equal( + mod.buildListResourceUrl("did:webvh:QmS:localhost%3A5173:user-a", "l1"), + "http://localhost:5173/user-a/resources/list-l1" + ); +}); + +test("isStaleDidDomain flags only DIDs off the current domain", async () => { + const mod = await loadModule({ native: false, envDomain: undefined, host: "boop.ad" }); + + assert.equal(mod.isStaleDidDomain("did:webvh:QmS:trypoo.app:user-a"), true); + assert.equal(mod.isStaleDidDomain("did:webvh:QmS:boop.ad:user-a"), false); + assert.equal( + mod.isStaleDidDomain("did:temp:20ed9d43"), + false, + "a non-webvh DID must not trigger a re-mint" + ); +}); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 2cd2aff..0ee6775 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -27,6 +27,7 @@ import { type ReactNode, } from "react"; import { createUserWebVHDid } from "../lib/webvh"; +import { useDidDomainRemint } from "./useDidDomainRemint"; import { storageAdapter } from "../lib/storageAdapter"; import { identifyUser, resetAnalytics } from "../lib/analytics"; @@ -402,6 +403,9 @@ export function AuthProvider({ children }: AuthProviderProps) { await storageAdapter.remove(JWT_STORAGE_KEY); }, []); + // Repairs identities minted on a domain we no longer serve. Temporary. + useDidDomainRemint(user); + const value: AuthContextValue = { isAuthenticated: user !== null, isLoading, diff --git a/src/hooks/useDidDomainRemint.ts b/src/hooks/useDidDomainRemint.ts new file mode 100644 index 0000000..feeb4bb --- /dev/null +++ b/src/hooks/useDidDomainRemint.ts @@ -0,0 +1,64 @@ +/** + * One-shot repair for identities minted on a domain we no longer serve. + * + * A did:webvh encodes its domain in the identifier and ours are non-portable, + * so an identity minted while the app lived on an old host names that host + * forever — and since a did:webvh resolves by fetching did.jsonl from its own + * domain, those DIDs are unresolvable and every list published under them fails + * to verify. + * + * The mint must happen here, not on the server: the controller key lives in this + * browser's localStorage and nowhere else. The same key is reused, so the new + * DID has the same controller — that is also what authorizes the server swap. + * + * TEMPORARY. Delete once no account reports a stale domain; it is a repair pass, + * not a permanent compatibility layer. + */ + +import { useEffect, useRef } from "react"; +import { useAction } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { createUserWebVHDid, isStaleDidDomain } from "../lib/webvh"; +import { Sentry } from "../lib/sentry"; + +export function useDidDomainRemint(user: { + did?: string; + email?: string; + turnkeySubOrgId?: string; +} | null) { + const remint = useAction(api.remintDid.remintUserDid); + // Guards against a second run while the first is in flight — the effect + // re-fires as `user` identity changes across renders. + const attempted = useRef(null); + + useEffect(() => { + const did = user?.did; + const email = user?.email; + const subOrgId = user?.turnkeySubOrgId; + if (!did || !email || !subOrgId) return; + if (!isStaleDidDomain(did)) return; + if (attempted.current === did) return; + attempted.current = did; + + void (async () => { + try { + // Same localStorage key, current domain — only the domain and SCID move. + const minted = await createUserWebVHDid({ email, subOrgId }); + const result = await remint({ + oldDid: did, + newDidLog: minted.didLogJsonl, + path: minted.path, + }); + if ("newDid" in result) { + console.info(`[remint] ${did} -> ${result.newDid} (${result.rewritten} rows)`); + } + } catch (err) { + // Never block login on this. The old DID keeps working as an identifier; + // it just stays unresolvable until a later attempt succeeds. + console.error("[remint] failed", err); + Sentry.captureException(err); + attempted.current = null; + } + })(); + }, [user?.did, user?.email, user?.turnkeySubOrgId, remint]); +} diff --git a/src/lib/webvh.ts b/src/lib/webvh.ts index 4b60132..d3d4042 100644 --- a/src/lib/webvh.ts +++ b/src/lib/webvh.ts @@ -132,6 +132,36 @@ export function pathFromDid(did: string): string { return parts.slice(4).join(":"); } +/** + * The domain a DID minted right now would carry. + * + * The native check comes FIRST on purpose. VITE_WEBVH_DOMAIN is baked in at + * build time, so a locally-built app (`bun run cap:build` reads .env.local) + * used to stamp a dev host like `localhost:5173` into real native DIDs, + * overriding the boop.ad branch. The env override is dev-only now. + */ +export function currentWebvhDomain(): string { + if (Capacitor.isNativePlatform()) return "boop.ad"; + return (import.meta.env.VITE_WEBVH_DOMAIN as string | undefined) || window.location.host; +} + +/** The domain encoded in a did:webvh, percent-decoded (`localhost%3A5173` → `localhost:5173`). */ +export function domainFromDidSafe(did: string): string | null { + const parts = did.split(":"); + if (parts.length < 5 || parts[1] !== "webvh") return null; + try { + return decodeURIComponent(parts[3]); + } catch { + return parts[3]; + } +} + +/** True when this DID names a domain we no longer mint on — its did.jsonl is unreachable. */ +export function isStaleDidDomain(did: string): boolean { + const domain = domainFromDidSafe(did); + return domain !== null && domain !== currentWebvhDomain(); +} + export async function createUserWebVHDid(params: { email: string; subOrgId: string; @@ -139,9 +169,7 @@ export async function createUserWebVHDid(params: { }) { const { privateKey, publicKeyMultibase } = await getOrCreateKeyPair(params.subOrgId); const signer = new BrowserWebVHSigner(privateKey, publicKeyMultibase); - const host = Capacitor.isNativePlatform() ? 'boop.ad' : window.location.host; - const domain = - params.domain || (import.meta.env.VITE_WEBVH_DOMAIN as string | undefined) || host; + const domain = params.domain || currentWebvhDomain(); const userSlug = toUserSlug(params.email, params.subOrgId); @@ -191,7 +219,9 @@ export function domainFromDid(did: string): string { if (parts.length < 4 || parts[1] !== "webvh") { throw new Error(`Cannot extract domain from DID: ${did}`); } - return parts[3]; + // A host with a port is stored percent-encoded (`localhost%3A5173`), because + // `:` is the DID's own separator. Decode it or the URL has an invalid host. + return decodeURIComponent(parts[3]); } /** @@ -209,5 +239,8 @@ export function buildListResourceDid(userDid: string, listId: string): string { export function buildListResourceUrl(userDid: string, listId: string): string { const domain = domainFromDid(userDid); const path = pathFromDid(userDid); - return `https://${domain}/${path}/resources/list-${listId}`; + // did:webvh mandates https; local hosts have no certificate, so dev links + // would be unopenable otherwise. + const scheme = /^(localhost|127\.0\.0\.1)(:|$)/.test(domain) ? "http" : "https"; + return `${scheme}://${domain}/${path}/resources/list-${listId}`; }