Skip to content

Re-mint user did:webvh onto the current WEBVH_DOMAIN - #215

Merged
brianorwhatever merged 1 commit into
mainfrom
feat/remint-user-did
Jul 29, 2026
Merged

Re-mint user did:webvh onto the current WEBVH_DOMAIN#215
brianorwhatever merged 1 commit into
mainfrom
feat/remint-user-did

Conversation

@brianorwhatever

@brianorwhatever brianorwhatever commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds a migration to move a user's did:webvh off a stale domain (e.g. trypoo.app) onto the domain the deployment mints on today.

Why a re-mint and not an update

did:webvh puts the domain inside the identifier, and these DIDs are created portable: false (convex/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.

That's worse than a cosmetic share-link bug: 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.

Prod WEBVH_DOMAIN is already boop.ad, so new signups are fine — this is only for identities minted earlier.

What it does

createDIDWebVH is re-run with the same Turnkey key and the same slug (user-{subOrgId:16}), so only the domain and derived SCID change. The user keeps their keys; only the identifier moves.

Then every row keyed to the old DID moves with it — otherwise it's orphaned (lists invisible to their owner, assignments pointing at nobody). The rewrite surface is declared as data at the top of remintUserDidDb.ts — 17 tables, 24 fields — so it can be read against schema.ts line by line instead of trusting 17 hand-written blocks. Three shapes:

Shape Example
Exact match lists.ownerDid, items.createdByDid, itemAssignees.*
Prefix match publications.webvhDid = {userDid}/resources/list-{id}
Nested lists.vcProof (incl. serialized proof JSON), items.vcProofs[], activities.metadata.assigneeDid

Deliberately not rewritten

  • lists.assetDid / listEnvelopes — those are did:cel asset ids, not user DIDs. A test asserts they're never touched.
  • The did:cel envelope's genesis content. It records createdBy: <old DID> and is signed — rewriting it would invalidate the signature added in Finish the Originals 2.x upgrade: did:cel genesis + persisted CEL logs #213. Envelopes keep the creating DID as a historical record, so the provenance panel will show the old creator DID against a list whose owner row has moved. Reconciling those is re-genesis of every list, not a field rewrite.

Not using legacyDid

The mechanism exists and is already threaded through ~295 call sites from the Turnkey migration, so reusing it would have been nearly free. Skipped on purpose: it's dual-identity legacy threading, and users.legacyDid already holds the pre-Turnkey DID — writing to it would clobber that. This rewrites cleanly instead.

Safety

  • The identity row moves last, so a mid-run failure leaves did on the old value and the whole run is safe to retry.
  • Idempotent — a user already on the target domain is skipped, with no partial rewrite.
  • preview counts affected rows and mutates nothing.

8 new tests cover each rewrite shape plus the ways this could silently corrupt data: another user's DID must survive, did:cel ids must not move, a re-run must skip rather than half-apply, and domain matching must decode %3A. Suite is 109 pass / 0 fail; both typechecks clean.

Running it (after merge + deploy)

Dry run first — counts only:

npx convex run --prod migrations/remintUserDid:preview '{"email":"you@..."}'

Then:

npx convex run --prod migrations/remintUserDid:remintByEmail '{"email":"you@..."}'

⚠️ Old https://<old-domain>/... share URLs break permanently — though they're already broken if that domain stopped serving.

🤖 Generated with Claude Code

Note

Add internal actions to re-mint user did:webvh DIDs onto the current WEBVH_DOMAIN

  • Adds remintByEmail internal action that mints a new did:webvh for a user via their Turnkey sub-org and rewrites all dependent rows across tables; skips idempotently if the user is already on the target domain.
  • Adds preview internal action for a dry-run that returns per-table row counts affected without making any mutations.
  • applyRemint rewrites flat fields, prefix-matched fields (e.g. publications.webvhDid), and nested DID references inside vcProof, vcProofs, and activity metadata.
  • Adds integration-style tests in scripts/remint-did.test.mjs covering rewrites, idempotency, and domain decoding against an in-memory mock.
  • Risk: applyRemint patches many tables in a single mutation; large datasets per user could hit Convex mutation size or time limits.

Macroscope summarized 508fd43.

A did:webvh encodes its domain in the identifier, and these DIDs are minted
`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.
That is worse than a cosmetic share-link problem: a did:webvh resolves by
fetching did.jsonl from its own domain, so once the old domain stops serving,
the DID is unresolvable and lists published under it cannot be verified.

Re-mint 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 and
only the identifier moves.

Every row keyed to the old DID has to move with it or it is orphaned (lists
invisible to their owner, assignments pointing at nobody). The rewrite surface
is declared as data — 17 tables, 24 fields — so it can be read against schema.ts
rather than trusting hand-rolled blocks. Three shapes:
- exact match: lists.ownerDid, items.createdByDid, itemAssignees.*, ...
- prefix match: publications.webvhDid is `{userDid}/resources/list-{id}`
- nested: lists.vcProof (incl. the serialized proof JSON), items.vcProofs[],
  activities.metadata.assigneeDid

Deliberately NOT rewritten:
- lists.assetDid / listEnvelopes — did:cel asset ids, not user DIDs.
- The did:cel envelope's genesis content records `createdBy: <old DID>` and is
  SIGNED. Rewriting it would invalidate the signature, so envelopes keep the
  creating DID as a historical record. Provenance will show the old creator DID
  against a list whose owner row has moved; reconciling them is re-genesis, not
  a field rewrite.

Does not use the legacyDid mechanism. It exists and is already threaded through
~295 call sites from the Turnkey migration, but it is dual-identity legacy
threading, and users.legacyDid already holds the pre-Turnkey DID — writing to it
would clobber that. This rewrites cleanly instead.

The identity row moves LAST, so a mid-run failure leaves `did` on the old value
and the whole run is safe to retry. `preview` counts rows without mutating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brianorwhatever
brianorwhatever merged commit 6ac2cee into main Jul 29, 2026
6 checks passed
brianorwhatever added a commit that referenced this pull request Jul 29, 2026
…ves (#216)

#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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant