Re-mint stale-domain DIDs from the client, where the signing key actually lives - #216
Merged
Conversation
#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>
brianorwhatever
added a commit
that referenced
this pull request
Jul 29, 2026
#216 required the new DID log to be signed by the key controlling the old DID. Signing in on prod showed that is unsatisfiable: Key did:key:z6MktvNdMfyjcW6eFDgALRK2cYbdEU9uxqRxLxBy65mRuXah is not authorized to update. didwebvh-ts raises that when a log's proof key is absent from its updateKeys. The browser's current localStorage key is not the key that minted the existing DID — localStorage is per-origin, and the original key went with the origin the DID was minted on. So the key-intersection check could never pass, and the hook failed client-side before reaching Convex (which is why prod logs showed no invocation). There is no continuity left to preserve, so the re-mint now issues a genuinely NEW identity and is authorized differently: POST /api/user/remintDid runs requireAuth(), takes the account from the verified token, and rewrites only that account's rows. The request body cannot name a victim. This is strictly safer than the status quo rather than a relaxation: /api/user/updateDID already lets an authenticated user set their DID to anything and silently strands every row on the old value. Pairing the swap with applyRemint closes that. Also extracts getConvexHttpUrl into lib/convexUrls so the hook can reach HTTP actions without importing the auth provider (which imports the hook). The client reloads after a successful re-mint: the app is keyed by DID throughout, so live subscriptions would otherwise hold rows under the old one. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #215. Running that migration on prod surfaced a design error in it.
What went wrong
convex/didCreation.ts:createDIDWebVH— the function #215 built on — had no callers. It was dead code. The live path isuseAuth.tsx:178→createUserWebVHDid, which mints in the browser with an Ed25519 key fromlocalStorage(getOrCreateKeyPair).schema.ts:48says it outright: "created client-side".So this was never a Turnkey policy problem. The server has never held the key, and can't mint a replacement the user would still control — re-minting with a fresh server key would hand back a DID they couldn't update.
The failure was clean: the mint runs before any rewrite and the identity row moves last, so prod data was verified untouched afterwards (all 375 rows still on the old DID).
What changed
applyRemint— the tested 17-table/24-field rewrite doesn't care where the new DID came from.useDidDomainRemint, only when the DID's domain ≠ currentWEBVH_DOMAIN.Authorization — the part that needed care
applyRemintreassigns every row from one DID to another. Exposed naively that's an account-takeover primitive, and this app's other mutations do trust client-supplied DIDs. SoremintDid:remintUserDidproves control instead:resolveDIDFromLogverifies each entry's Data Integrity proof, so a log can't be forged without the key.WEBVH_DOMAIN, so it can only ever move an identity onto the canonical domain.Any failure throws before a row is touched.
Also fixes the original share-link bug
domainFromDidreturnedparts[3]undecoded, so a host with a port producedhttps://localhost%3A5173/...— an invalid host. Now decoded, and localhost getshttpsince it has no certificate.currentWebvhDomainchecksisNativePlatform()beforeVITE_WEBVH_DOMAIN. That env var is baked in at build time, sobun run cap:build(which reads.env.local) used to stamp a dev host into real native DIDs, overriding theboop.adbranch. That's plausibly how this class of bug happened in the first place.Safety
useDidDomainRemintis explicitly temporary — a repair pass, not a compatibility layer — and never blocks login. On failure the old DID keeps working as an identifier (just unresolvable) and it retries next load.Tests
5 new, covering the native-precedence regression, percent-decoding, both URL schemes, and that a non-
did:webvhidentifier never triggers a re-mint. Suite 114 pass / 0 fail; both typechecks clean; lint unchanged from baseline.One of these tests initially leaked
globalThis.window, which made unrelated test files believe they were in a browser — fixed by scoping the global to each call, so the suite stays honest under bun's shared-process runner.After merge
Each of the 6 accounts re-mints itself on next login. Verify with:
which should drain to
[].did:celenvelopes keep the old DID in their signed genesis content, so provenance will still show the old creator DID. Reconciling that is re-genesis, not a rewrite.🤖 Generated with Claude Code
Note
Re-mint stale-domain DIDs from the client where the signing key lives
useDidDomainReminthook that detects when a user'sdid:webvhreferences a stale domain, mints a new DID in the browser using the existing localStorage key, and calls the newapi.remintDid.remintUserDidaction to rewrite server-side records.currentWebvhDomain()tosrc/lib/webvh.tsto centralize domain selection — native builds now always useboop.adregardless ofVITE_WEBVH_DOMAIN.domainFromDidto percent-decode the domain component (e.g.localhost%3A5173→localhost:5173) and fixesbuildListResourceUrlto usehttpfor localhost.migrations/remintUserDidaction and replaces it with new DB helpers (findUserByDid,getDidLogFor,storeDidLog) inconvex/migrations/remintUserDidDb.ts.Macroscope summarized f43ef8a.