diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index e23f87d..687993b 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -56,6 +56,8 @@ 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"; import type * as originals from "../originals.js"; @@ -131,6 +133,8 @@ 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; originals: typeof originals; diff --git a/convex/migrations/remintUserDid.ts b/convex/migrations/remintUserDid.ts new file mode 100644 index 0000000..a932ae3 --- /dev/null +++ b/convex/migrations/remintUserDid.ts @@ -0,0 +1,114 @@ +"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 new file mode 100644 index 0000000..2fc8efd --- /dev/null +++ b/convex/migrations/remintUserDidDb.ts @@ -0,0 +1,219 @@ +/** + * Database half of the user did:webvh re-mint. + * + * Split from remintUserDid.ts because that file is "use node" (Turnkey signing) + * and Convex only allows actions in Node modules. + * + * A did:webvh encodes its domain in the identifier and these DIDs are minted + * `portable: false` (didCreation.ts:57), so a domain change is a NEW DID, not an + * update. Every row keyed to the old DID has to move with it or it is orphaned: + * lists become invisible to their owner, assignments point at nobody. + * + * The rewrite surface is declared as data below rather than written out as 17 + * hand-rolled blocks, so it can be read against schema.ts field by field. + */ + +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "../_generated/server"; +import type { MutationCtx } from "../_generated/server"; + +/** + * Tables holding a user DID by value, as (table -> exact-match fields). + * Deliberately excludes: + * - lists.assetDid / listEnvelopes.assetDid — did:cel asset ids, not user DIDs + * - sites.did — the site's own did:webvh + * - users.did / users.legacyDid — handled separately, they ARE the identity + */ +const EXACT_MATCH_FIELDS: Record = { + didLogs: ["userDid"], + agentApiKeys: ["ownerDid", "agentDid"], + categories: ["ownerDid"], + bookmarks: ["userDid"], + lists: ["ownerDid"], + items: ["createdByDid", "checkedByDid", "assigneeDid"], + itemAssignees: ["assigneeDid", "assignedByDid"], + activities: ["actorDid"], + presence: ["userDid"], + tags: ["createdByDid"], + listTemplates: ["ownerDid"], + pushSubscriptions: ["userDid"], + pushTokens: ["userDid"], + publications: ["publishedByDid"], + sites: ["ownerDid"], + comments: ["userDid"], + bitcoinAnchors: ["requestedByDid"], +}; + +/** + * publications.webvhDid is `{userDid}/resources/list-{id}` — the DID is a prefix, + * not the whole value, so it needs a prefix swap rather than an equality swap. + */ +const PREFIX_MATCH_FIELDS: Record = { + publications: ["webvhDid"], +}; + +type Row = Record & { _id: string }; + +/** Rewrites nested DID-bearing objects the flat field map can't reach. */ +function rewriteNested(table: string, row: Row, oldDid: string, newDid: string): Record | null { + const patch: Record = {}; + + if (table === "lists" && row.vcProof) { + const vc = row.vcProof as { + issuer: string; + credentialSubject: { id: string; ownerDid: string }; + proof?: string; + }; + const next = { + ...vc, + issuer: vc.issuer === oldDid ? newDid : vc.issuer, + credentialSubject: { + ...vc.credentialSubject, + ownerDid: + vc.credentialSubject.ownerDid === oldDid ? newDid : vc.credentialSubject.ownerDid, + }, + // The serialized credential embeds the DID in JSON; swap every occurrence. + ...(vc.proof ? { proof: vc.proof.split(oldDid).join(newDid) } : {}), + }; + if (JSON.stringify(next) !== JSON.stringify(vc)) patch.vcProof = next; + } + + if (table === "items" && Array.isArray(row.vcProofs)) { + const proofs = row.vcProofs as Array<{ issuer: string; actorDid: string; proof?: string }>; + const next = proofs.map((p) => ({ + ...p, + issuer: p.issuer === oldDid ? newDid : p.issuer, + actorDid: p.actorDid === oldDid ? newDid : p.actorDid, + ...(p.proof ? { proof: p.proof.split(oldDid).join(newDid) } : {}), + })); + if (JSON.stringify(next) !== JSON.stringify(proofs)) patch.vcProofs = next; + } + + if (table === "activities" && row.metadata) { + const meta = row.metadata as { assigneeDid?: string }; + if (meta.assigneeDid === oldDid) { + patch.metadata = { ...meta, assigneeDid: newDid }; + } + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +export const findUserByEmail = internalQuery({ + args: { email: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", args.email)) + .first(); + if (!user || !user.did) return null; + return { + _id: user._id, + did: user.did, + email: user.email, + turnkeySubOrgId: user.turnkeySubOrgId, + }; + }, +}); + +/** Users whose DID sits on a domain other than the one we mint on today. */ +export const listUsersOnDomain = internalQuery({ + args: { domain: v.string() }, + handler: async (ctx, args) => { + const users = await ctx.db.query("users").collect(); + return users + .filter((u) => { + if (!u.did) return false; + const parts = u.did.split(":"); + // did:webvh:{scid}:{domain}:{path...} — domain is percent-encoded. + return parts.length >= 5 && decodeURIComponent(parts[3]) === args.domain; + }) + .map((u) => ({ + _id: u._id, + did: u.did!, + email: u.email, + turnkeySubOrgId: u.turnkeySubOrgId, + })); + }, +}); + +/** Counts every row that would move, without changing anything. */ +export const previewRemint = internalQuery({ + args: { oldDid: v.string() }, + handler: async (ctx, args) => { + const counts: Record = {}; + + for (const [table, fields] of Object.entries(EXACT_MATCH_FIELDS)) { + const rows = (await ctx.db.query(table as never).collect()) as unknown as Row[]; + const n = rows.filter((r) => fields.some((f) => r[f] === args.oldDid)).length; + if (n > 0) counts[table] = n; + } + + for (const [table, fields] of Object.entries(PREFIX_MATCH_FIELDS)) { + const rows = (await ctx.db.query(table as never).collect()) as unknown as Row[]; + const n = rows.filter((r) => + fields.some((f) => typeof r[f] === "string" && (r[f] as string).startsWith(args.oldDid)) + ).length; + if (n > 0) counts[`${table}.prefix`] = n; + } + + return counts; + }, +}); + +export const applyRemint = internalMutation({ + args: { + userId: v.id("users"), + oldDid: v.string(), + newDid: v.string(), + }, + handler: async (ctx: MutationCtx, args) => { + const user = await ctx.db.get(args.userId); + if (!user) throw new Error(`User ${args.userId} not found`); + if (user.did !== args.oldDid) { + // Someone already re-minted this user; don't rewrite a second time. + return { rewritten: 0, skipped: true }; + } + + let rewritten = 0; + + for (const [table, fields] of Object.entries(EXACT_MATCH_FIELDS)) { + const rows = (await ctx.db.query(table as never).collect()) as unknown as Row[]; + for (const row of rows) { + const patch: Record = {}; + for (const field of fields) { + if (row[field] === args.oldDid) patch[field] = args.newDid; + } + const nested = rewriteNested(table, row, args.oldDid, args.newDid); + if (nested) Object.assign(patch, nested); + if (Object.keys(patch).length > 0) { + await ctx.db.patch(row._id as never, patch as never); + rewritten += 1; + } + } + } + + for (const [table, fields] of Object.entries(PREFIX_MATCH_FIELDS)) { + const rows = (await ctx.db.query(table as never).collect()) as unknown as Row[]; + for (const row of rows) { + const patch: Record = {}; + for (const field of fields) { + const value = row[field]; + if (typeof value === "string" && value.startsWith(args.oldDid)) { + patch[field] = args.newDid + value.slice(args.oldDid.length); + } + } + if (Object.keys(patch).length > 0) { + await ctx.db.patch(row._id as never, patch as never); + rewritten += 1; + } + } + } + + // The identity row moves last: if anything above throws, `did` still points + // at the old value and the whole run is safe to retry. + await ctx.db.patch(args.userId, { did: args.newDid }); + + return { rewritten, skipped: false }; + }, +}); diff --git a/scripts/remint-did.test.mjs b/scripts/remint-did.test.mjs new file mode 100644 index 0000000..119259a --- /dev/null +++ b/scripts/remint-did.test.mjs @@ -0,0 +1,221 @@ +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/remint-did-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/migrations/remintUserDidDb.ts"], + outfile: `${outdir}/remintUserDidDb.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/remintUserDidDb.mjs`).href}?t=${Date.now()}` + ); +} + +const mod = await loadModule(); +const unwrap = (fn) => fn._handler ?? fn.handler; + +const OLD = "did:webvh:QmOLDSCID:trypoo.app:user-abc123"; +const NEW = "did:webvh:QmNEWSCID:boop.ad:user-abc123"; +const OTHER = "did:webvh:QmOTHER:boop.ad:user-zzz999"; + +/** In-memory ctx across multiple tables, mirroring the Convex surface used. */ +function makeCtx(tables) { + const store = new Map(Object.entries(tables).map(([t, rows]) => [t, rows.map((r) => ({ ...r }))])); + const byId = new Map(); + for (const rows of store.values()) for (const r of rows) byId.set(r._id, r); + return { + store, + db: { + query: (table) => ({ + collect: async () => store.get(table) ?? [], + withIndex: (_n, fn) => { + const eqs = []; + fn({ eq: (f, val) => (eqs.push([f, val]), { eq: () => {} }) }); + const rows = (store.get(table) ?? []).filter((r) => eqs.every(([f, val]) => r[f] === val)); + return { first: async () => rows[0] ?? null, collect: async () => rows }; + }, + }), + get: async (id) => byId.get(id) ?? null, + patch: async (id, fields) => Object.assign(byId.get(id), fields), + }, + }; +} + +function baseTables(overrides = {}) { + return { + users: [{ _id: "u1", did: OLD, email: "me@example.com", turnkeySubOrgId: "sub1" }], + lists: [ + { _id: "l1", ownerDid: OLD, name: "Mine" }, + { _id: "l2", ownerDid: OTHER, name: "Theirs" }, + ], + items: [], + itemAssignees: [], + activities: [], + publications: [], + didLogs: [], + agentApiKeys: [], + categories: [], + bookmarks: [], + presence: [], + tags: [], + listTemplates: [], + pushSubscriptions: [], + pushTokens: [], + sites: [], + comments: [], + bitcoinAnchors: [], + ...overrides, + }; +} + +test("rewrites the owner DID but leaves other users alone", async () => { + const ctx = makeCtx(baseTables()); + const res = await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + + assert.equal(res.skipped, false); + const lists = ctx.store.get("lists"); + assert.equal(lists.find((l) => l._id === "l1").ownerDid, NEW); + assert.equal(lists.find((l) => l._id === "l2").ownerDid, OTHER, "other users must not move"); + assert.equal(ctx.store.get("users")[0].did, NEW, "identity row must move"); +}); + +test("publications.webvhDid is prefix-rewritten, keeping the resource path", async () => { + const ctx = makeCtx( + baseTables({ + publications: [ + { _id: "p1", webvhDid: `${OLD}/resources/list-l1`, publishedByDid: OLD }, + { _id: "p2", webvhDid: `${OTHER}/resources/list-l2`, publishedByDid: OTHER }, + ], + }) + ); + await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + + const pubs = ctx.store.get("publications"); + assert.equal( + pubs.find((p) => p._id === "p1").webvhDid, + `${NEW}/resources/list-l1`, + "the /resources/list-* suffix must survive the prefix swap" + ); + assert.equal(pubs.find((p) => p._id === "p1").publishedByDid, NEW); + assert.equal(pubs.find((p) => p._id === "p2").webvhDid, `${OTHER}/resources/list-l2`); +}); + +test("nested list vcProof is rewritten, including the serialized proof", async () => { + const ctx = makeCtx( + baseTables({ + lists: [ + { + _id: "l1", + ownerDid: OLD, + name: "Mine", + vcProof: { + type: "ListOwnershipCredential", + issuer: OLD, + issuanceDate: 1, + credentialSubject: { id: "did:cel:uEiXYZ", ownerDid: OLD }, + proof: JSON.stringify({ issuer: OLD, credentialSubject: { id: OLD } }), + }, + }, + ], + }) + ); + await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + + const vc = ctx.store.get("lists")[0].vcProof; + assert.equal(vc.issuer, NEW); + assert.equal(vc.credentialSubject.ownerDid, NEW); + assert.equal(vc.credentialSubject.id, "did:cel:uEiXYZ", "asset DID must not be touched"); + assert.ok(!vc.proof.includes("trypoo.app"), "serialized proof must not retain the old DID"); +}); + +test("nested item vcProofs and activity metadata are rewritten", async () => { + const ctx = makeCtx( + baseTables({ + items: [ + { + _id: "i1", + createdByDid: OLD, + checkedByDid: OTHER, + vcProofs: [{ type: "ItemCreation", issuer: OLD, issuanceDate: 1, action: "created", actorDid: OLD }], + }, + ], + activities: [{ _id: "a1", actorDid: OLD, metadata: { assigneeDid: OLD, status: "active" } }], + }) + ); + await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + + const item = ctx.store.get("items")[0]; + assert.equal(item.createdByDid, NEW); + assert.equal(item.checkedByDid, OTHER, "another user's DID must survive"); + assert.equal(item.vcProofs[0].actorDid, NEW); + assert.equal(item.vcProofs[0].issuer, NEW); + + const act = ctx.store.get("activities")[0]; + assert.equal(act.actorDid, NEW); + assert.equal(act.metadata.assigneeDid, NEW); + assert.equal(act.metadata.status, "active", "unrelated metadata must be preserved"); +}); + +test("the did:cel assetDid is never rewritten", async () => { + const ctx = makeCtx( + baseTables({ + lists: [{ _id: "l1", ownerDid: OLD, assetDid: "did:cel:uEiKEEPME", name: "Mine" }], + }) + ); + await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + assert.equal(ctx.store.get("lists")[0].assetDid, "did:cel:uEiKEEPME"); +}); + +test("is idempotent — a user already on the new DID is skipped", async () => { + const ctx = makeCtx( + baseTables({ users: [{ _id: "u1", did: NEW, email: "me@example.com", turnkeySubOrgId: "sub1" }] }) + ); + const res = await unwrap(mod.applyRemint)(ctx, { userId: "u1", oldDid: OLD, newDid: NEW }); + assert.equal(res.skipped, true); + assert.equal(res.rewritten, 0); + assert.equal(ctx.store.get("lists").find((l) => l._id === "l1").ownerDid, OLD, "no partial rewrite"); +}); + +test("previewRemint counts without mutating", async () => { + const ctx = makeCtx( + baseTables({ + publications: [{ _id: "p1", webvhDid: `${OLD}/resources/list-l1`, publishedByDid: OLD }], + }) + ); + const counts = await unwrap(mod.previewRemint)(ctx, { oldDid: OLD }); + + assert.equal(counts.lists, 1); + assert.equal(counts.publications, 1); + assert.equal(counts["publications.prefix"], 1); + assert.equal(ctx.store.get("lists").find((l) => l._id === "l1").ownerDid, OLD, "preview must not mutate"); +}); + +test("listUsersOnDomain matches percent-encoded domains", async () => { + const ctx = makeCtx( + baseTables({ + users: [ + { _id: "u1", did: OLD, email: "a@x.com" }, + { _id: "u2", did: "did:webvh:QmS:localhost%3A5173:user-b", email: "b@x.com" }, + { _id: "u3", did: NEW, email: "c@x.com" }, + ], + }) + ); + + const trypoo = await unwrap(mod.listUsersOnDomain)(ctx, { domain: "trypoo.app" }); + assert.deepEqual(trypoo.map((u) => u._id), ["u1"]); + + const local = await unwrap(mod.listUsersOnDomain)(ctx, { domain: "localhost:5173" }); + assert.deepEqual(local.map((u) => u._id), ["u2"], "must decode %3A before comparing"); +});