koala73/worldmonitor · error · ConvexError

ANON_CLAIM_PROOF_REQUIRED

ANON_CLAIM_PROOF_REQUIRED

Error message

ANON_CLAIM_PROOF_REQUIRED

What it means

Structured ConvexError thrown by the claimSubscription mutation when an anonymous subscription is claimed without a valid claim token. Claiming requires server-issued ownership proof issued during checkout; a bare leaked anon UUID alone is not sufficient, preventing cross-user subscription theft via injected anon ids (see issue #2078).

Solutions

  1. Pass the claimToken that was issued server-side when the anonymous subscription was created
  2. If the token was lost, use the recovery path instead of retrying with only the anonId
  3. Never accept or forward anon ids from untrusted user input as claim proof
Defensive patterns

Strategy: validation

When it happens

Trigger: Thrown at convex/payments/billing.ts:3718 when the library encounters an invalid state.

Common situations: See trigger scenarios.


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-08-21). Data as JSON: /api/errors/652376c5a655e81e. Report an issue: GitHub.

Appendix: source

Thrown at convex/payments/billing.ts:3777

 * 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, deletedCustomers] = 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),
      ctx.db.query("deletedSubscriptionCustomers").withIndex("by_userId", (q) => q.eq("userId", args.anonId)).collect(),
    ]);

    const hasClaimableRows =
      subs.length > 0 ||
      anonEntitlement !== null ||
      customers.length > 0 ||
      payments.length > 0 ||
      deletedCustomers.length > 0;
    if (!hasClaimableRows) {

View on GitHub (pinned to 7d06c8633d)