{"record":{"id":"652376c5a655e81e","repo":"koala73/worldmonitor","slug":"anon-claim-proof-required","errorCode":"ANON_CLAIM_PROOF_REQUIRED","errorMessage":"ANON_CLAIM_PROOF_REQUIRED","messagePattern":"ANON_CLAIM_PROOF_REQUIRED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/billing.ts","lineNumber":3718,"sourceCode":" * server-side during checkout creation; a leaked bare UUID is not sufficient\n * ownership proof.\n *\n * @see https://github.com/koala73/worldmonitor/issues/2078\n */\nexport const claimSubscription = mutation({\n  args: { anonId: v.string(), claimToken: v.optional(v.string()) },\n  handler: async (ctx, args) => {\n    const realUserId = await requireUserId(ctx);\n\n    // Validate anonId is a UUID v4 (format produced by crypto.randomUUID() in user-identity.ts).\n    // Rejects injected Clerk IDs (\"user_xxx\") which are structurally distinct from UUID v4,\n    // preventing cross-user subscription theft via localStorage injection.\n    if (!ANON_ID_V4_REGEX.test(args.anonId) || args.anonId === realUserId) {\n      return { claimed: { subscriptions: 0, entitlements: 0, customers: 0, payments: 0 } };\n    }\n\n    if (args.claimToken !== undefined && !(await verifyAnonClaimToken(args.anonId, args.claimToken))) {\n      throw new ConvexError({ kind: \"ANON_CLAIM_PROOF_REQUIRED\" });\n    }\n\n    // Parallel reads for all anonId data — bounded to prevent runaway memory\n    const [subs, anonEntitlement, customers, payments] = await Promise.all([\n      ctx.db.query(\"subscriptions\").withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.anonId)).take(50),\n      ctx.db.query(\"entitlements\").withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.anonId)).first(),\n      ctx.db.query(\"customers\").withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.anonId)).take(10),\n      ctx.db.query(\"paymentEvents\").withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.anonId)).take(1000),\n    ]);\n\n    const hasClaimableRows =\n      subs.length > 0 ||\n      anonEntitlement !== null ||\n      customers.length > 0 ||\n      payments.length > 0;\n    if (!hasClaimableRows) {\n      return { claimed: { subscriptions: 0, entitlements: 0, customers: 0, payments: 0 } };\n    }","sourceCodeStart":3700,"sourceCodeEnd":3736,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/billing.ts#L3700-L3736","documentation":"Thrown by the `claimAnonSubscription` mutation when a `claimToken` argument WAS supplied but `verifyAnonClaimToken(anonId, claimToken)` rejects it (wrong, expired, or already-used token). This is the proof-mismatch path: the client attempted a claim with a token, but the token does not verify against the anonId. Object-typed ConvexError (`{ kind: \"ANON_CLAIM_PROOF_REQUIRED\" }`).","triggerScenarios":"Calling `claimAnonSubscription({ anonId, claimToken })` where the token fails HMAC verification — e.g. token was generated for a different anonId, has expired, was already consumed, or was tampered with.","commonSituations":"User cleared localStorage so anonId changed but an old token was retried; the claim window expired; a copy-pasted token from another account; token already used in a previous successful claim.","solutions":["Request a fresh claim token for the current anonId and retry the claim.","Confirm the anonId passed matches the one the token was issued for (same browser/localStorage identity).","If the user is now signed in and has no anon data to migrate, treat the empty-claim result as success rather than retrying stale tokens."],"exampleFix":"// before\nawait claimAnonSubscription({ anonId, claimToken: staleToken });\n\n// after\nconst fresh = await requestAnonClaimToken({ anonId });\nawait claimAnonSubscription({ anonId, claimToken: fresh });","handlingStrategy":"try-catch","validationCode":"// Always obtain a fresh claim token before attempting a claim\nconst claimToken = await requestAnonClaimToken({ anonId });\nawait claimAnonSubscription({ anonId, claimToken });","typeGuard":null,"tryCatchPattern":"try {\n  await claimAnonSubscription({ anonId, claimToken });\n} catch (e: any) {\n  if (e?.data?.kind === \"ANON_CLAIM_PROOF_REQUIRED\") {\n    const fresh = await requestAnonClaimToken({ anonId });\n    await claimAnonSubscription({ anonId, claimToken: fresh });\n    return;\n  }\n  throw e;\n}","preventionTips":["Generate the claim token from the same anonId being claimed.","Don't reuse tokens across anonId changes (e.g. after localStorage clear)."],"tags":["billing","convex","auth","validation","anon-claim"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}