{"record":{"id":"a5ac931b4121cbca","repo":"koala73/worldmonitor","slug":"pro-required-a5ac93","errorCode":"PRO_REQUIRED","errorMessage":"PRO_REQUIRED","messagePattern":"PRO_REQUIRED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/mcpProTokens.ts","lineNumber":76,"sourceCode":"    // direct ctx.db read of the row uses the catalog default explicitly.\n    const entitlement = await ctx.db\n      .query(\"entitlements\")\n      .withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.userId))\n      .first();\n    const catalogDefaults = entitlement\n      ? getFeaturesForPlan(entitlement.planKey)\n      : null;\n    const mergedFeatures = entitlement && catalogDefaults\n      ? { ...catalogDefaults, ...entitlement.features }\n      : null;\n    if (\n      !entitlement ||\n      !mergedFeatures ||\n      entitlement.validUntil < Date.now() ||\n      mergedFeatures.tier < 1 ||\n      mergedFeatures.mcpAccess !== true\n    ) {\n      throw new ConvexError(\"PRO_REQUIRED\");\n    }\n\n    // Enforce per-user cap with silent oldest rotation. Match the pattern\n    // used by createApiKey at convex/apiKeys.ts:62 — count only non-revoked\n    // rows, but unlike apiKeys we silently rotate instead of throwing.\n    //\n    // F5 (U7+U8 review pass): \"exactly oldest\" rotation has a race —\n    // two concurrent issue calls can both observe `active.length === 4`,\n    // both insert, and produce 6 active rows. Convex doesn't serialise\n    // mutations across the entire table; per-userId concurrency is real.\n    // To converge back to the cap even after a brief race window, revoke\n    // ALL rows beyond `MAX_TOKENS_PER_USER - 1` (sorted by createdAt).\n    // This makes the cap \"eventually MAX\" rather than \"atomically MAX\":\n    // the next issue call's check trims any temporary overshoot.\n    const existing = await ctx.db\n      .query(\"mcpProTokens\")\n      .withIndex(\"by_userId\", (q) => q.eq(\"userId\", args.userId))\n      .collect();","sourceCodeStart":58,"sourceCodeEnd":94,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/mcpProTokens.ts#L58-L94","documentation":"Thrown by the `issueProMcpToken` internal mutation when the entitlement gate fails: no entitlement row, OR `mergedFeatures` is null (unknown planKey with no catalog default), OR `entitlement.validUntil < Date.now()` (expired), OR `mergedFeatures.tier < 1` (below Pro), OR `mergedFeatures.mcpAccess !== true`. The check mirrors the downstream MCP-edge gate so a token is never minted that would fail every `tools/call`. Legacy pre-FIELD entitlement rows are handled by catalog-default merge. Plain-string ConvexError; `err.data === \"PRO_REQUIRED\"`. This is an `internalMutation` (called by the server after Clerk grant validation), not a client-facing mutation.","triggerScenarios":"The `/oauth/authorize-pro` flow calls `issueProMcpToken` for a user whose entitlement row is missing, expired, has `tier: 0` (free), or has `mcpAccess: false`. Also when a plan downgrade to free hasn't yet revoked the ability but the entitlement row reflects free tier, or a `validUntil` timestamp is in the past due to a webhook/billing sync delay.","commonSituations":"A user subscribed via Dodo but the entitlement webhook hasn't synced yet (race between checkout completion and `issueProMcpToken`); a cancelled/expired subscription; a legacy user migrated without an entitlement row; a plan catalog change that set `mcpAccess: false` for a tier that previously had it.","solutions":["Ensure the entitlement row exists and `validUntil` is in the future before triggering the OAuth flow (await the Dodo webhook / sync).","Confirm the user's plan has `tier >= 1` AND `mcpAccess: true` in the plan catalog (`getFeaturesForPlan`).","If this is a webhook-sync race, retry `issueProMcpToken` after a short delay once the entitlement is written.","For legacy users, run the entitlement backfill so every paying user has a row with merged features.","Surface a clear \"Upgrade to Pro\" message to the end user; do NOT silently mint a token that will fail downstream."],"exampleFix":"// before — issuing before entitlement is confirmed\nawait ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });\n\n// after — verify entitlement first, handle the race\nconst ent = await ctx.runQuery(api.entitlements.getForUser, { userId });\nif (!ent || ent.validUntil < Date.now() || ent.features.tier < 1 || !ent.features.mcpAccess) {\n  throw new Error(\"User lacks Pro entitlement with MCP access\");\n}\nawait ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });","handlingStrategy":"validation","validationCode":"const ent = await convex.query(api.entitlements.getForUser, {});\nif (!ent || ent.validUntil < Date.now() || ent.features.tier < 1 || !ent.features.mcpAccess) {\n  showUpgradePrompt();\n  return;\n}","typeGuard":"function hasProMcpEntitlement(ent: { validUntil: number; features: { tier: number; mcpAccess?: boolean } } | null): boolean {\n  return !!ent && ent.validUntil >= Date.now() && ent.features.tier >= 1 && ent.features.mcpAccess === true;\n}","tryCatchPattern":"// issueProMcpToken is an internalMutation — the catch lives in the action that calls it\ntry {\n  await ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });\n} catch (err) {\n  if (err.data === \"PRO_REQUIRED\") throw new Error(\"User lacks Pro entitlement with MCP access\");\n  else throw err;\n}","preventionTips":["Await the Dodo entitlement webhook before triggering the OAuth/token flow.","Confirm tier >= 1 AND mcpAccess === true in the plan catalog.","Run the entitlement backfill for legacy paying users.","This is an internalMutation — the gate mirrors the MCP-edge gate to avoid minting dead tokens.","Retry only on a webhook-sync race, never on a deterministic denial."],"tags":["auth","convex","entitlement","pro","mcp"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}