koala73/worldmonitor · error · ConvexError
ANON_CLAIM_PROOF_REQUIRED
ANON_CLAIM_PROOF_REQUIRED
Error message
ANON_CLAIM_PROOF_REQUIRED
What it means
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" }`).
Source
Thrown at convex/payments/billing.ts:3718
* server-side during checkout creation; a leaked bare UUID is not sufficient
* ownership proof.
*
* @see https://github.com/koala73/worldmonitor/issues/2078
*/
export const claimSubscription = mutation({
args: { anonId: v.string(), claimToken: v.optional(v.string()) },
handler: async (ctx, args) => {
const realUserId = await requireUserId(ctx);
// Validate anonId is a UUID v4 (format produced by crypto.randomUUID() in user-identity.ts).
// Rejects injected Clerk IDs ("user_xxx") which are structurally distinct from UUID v4,
// preventing cross-user subscription theft via localStorage injection.
if (!ANON_ID_V4_REGEX.test(args.anonId) || args.anonId === realUserId) {
return { claimed: { subscriptions: 0, entitlements: 0, customers: 0, payments: 0 } };
}
if (args.claimToken !== undefined && !(await verifyAnonClaimToken(args.anonId, args.claimToken))) {
throw new ConvexError({ kind: "ANON_CLAIM_PROOF_REQUIRED" });
}
// Parallel reads for all anonId data — bounded to prevent runaway memory
const [subs, anonEntitlement, customers, payments] = await Promise.all([
ctx.db.query("subscriptions").withIndex("by_userId", (q) => q.eq("userId", args.anonId)).take(50),
ctx.db.query("entitlements").withIndex("by_userId", (q) => q.eq("userId", args.anonId)).first(),
ctx.db.query("customers").withIndex("by_userId", (q) => q.eq("userId", args.anonId)).take(10),
ctx.db.query("paymentEvents").withIndex("by_userId", (q) => q.eq("userId", args.anonId)).take(1000),
]);
const hasClaimableRows =
subs.length > 0 ||
anonEntitlement !== null ||
customers.length > 0 ||
payments.length > 0;
if (!hasClaimableRows) {
return { claimed: { subscriptions: 0, entitlements: 0, customers: 0, payments: 0 } };
}View on GitHub (pinned to ffec79ac33)
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.
Example fix
// before
await claimAnonSubscription({ anonId, claimToken: staleToken });
// after
const fresh = await requestAnonClaimToken({ anonId });
await claimAnonSubscription({ anonId, claimToken: fresh }); Defensive patterns
Strategy: try-catch
Validate before calling
// Always obtain a fresh claim token before attempting a claim
const claimToken = await requestAnonClaimToken({ anonId });
await claimAnonSubscription({ anonId, claimToken }); Try / catch
try {
await claimAnonSubscription({ anonId, claimToken });
} catch (e: any) {
if (e?.data?.kind === "ANON_CLAIM_PROOF_REQUIRED") {
const fresh = await requestAnonClaimToken({ anonId });
await claimAnonSubscription({ anonId, claimToken: fresh });
return;
}
throw e;
} Prevention
- Generate the claim token from the same anonId being claimed.
- Don't reuse tokens across anonId changes (e.g. after localStorage clear).
When it happens
Trigger: 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.
Common situations: 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.
Related errors
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/652376c5a655e81e.
Report an issue: GitHub.