Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions convex/migrations/remintUserDid.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> => {
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 };
},
});
219 changes: 219 additions & 0 deletions convex/migrations/remintUserDidDb.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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<string, string[]> = {
publications: ["webvhDid"],
};

type Row = Record<string, unknown> & { _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<string, unknown> | null {
const patch: Record<string, unknown> = {};

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<string, number> = {};

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<string, unknown> = {};
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<string, unknown> = {};
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 };
},
});
Loading
Loading