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

  1. Request a fresh claim token for the current anonId and retry the claim.
  2. Confirm the anonId passed matches the one the token was issued for (same browser/localStorage identity).
  3. 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

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.