From 508fd43d150ce37f7898ac4550af358c862fd357 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 29 Jul 2026 01:54:08 -0700 Subject: [PATCH] feat(migrations): re-mint user did:webvh onto the current WEBVH_DOMAIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` 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) --- convex/_generated/api.d.ts | 4 + convex/migrations/remintUserDid.ts | 114 ++++++++++++++ convex/migrations/remintUserDidDb.ts | 219 ++++++++++++++++++++++++++ scripts/remint-did.test.mjs | 221 +++++++++++++++++++++++++++ 4 files changed, 558 insertions(+) create mode 100644 convex/migrations/remintUserDid.ts create mode 100644 convex/migrations/remintUserDidDb.ts create mode 100644 scripts/remint-did.test.mjs 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"); +});