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
- Pass the claimToken that was issued server-side when the anonymous subscription was created
- If the token was lost, use the recovery path instead of retrying with only the anonId
- 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)