koala73/worldmonitor · error · ConvexError

PRO_REQUIRED

PRO_REQUIRED

Error message

PRO_REQUIRED

What it means

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.

Source

Thrown at convex/mcpProTokens.ts:76

    // direct ctx.db read of the row uses the catalog default explicitly.
    const entitlement = await ctx.db
      .query("entitlements")
      .withIndex("by_userId", (q) => q.eq("userId", args.userId))
      .first();
    const catalogDefaults = entitlement
      ? getFeaturesForPlan(entitlement.planKey)
      : null;
    const mergedFeatures = entitlement && catalogDefaults
      ? { ...catalogDefaults, ...entitlement.features }
      : null;
    if (
      !entitlement ||
      !mergedFeatures ||
      entitlement.validUntil < Date.now() ||
      mergedFeatures.tier < 1 ||
      mergedFeatures.mcpAccess !== true
    ) {
      throw new ConvexError("PRO_REQUIRED");
    }

    // Enforce per-user cap with silent oldest rotation. Match the pattern
    // used by createApiKey at convex/apiKeys.ts:62 — count only non-revoked
    // rows, but unlike apiKeys we silently rotate instead of throwing.
    //
    // F5 (U7+U8 review pass): "exactly oldest" rotation has a race —
    // two concurrent issue calls can both observe `active.length === 4`,
    // both insert, and produce 6 active rows. Convex doesn't serialise
    // mutations across the entire table; per-userId concurrency is real.
    // To converge back to the cap even after a brief race window, revoke
    // ALL rows beyond `MAX_TOKENS_PER_USER - 1` (sorted by createdAt).
    // This makes the cap "eventually MAX" rather than "atomically MAX":
    // the next issue call's check trims any temporary overshoot.
    const existing = await ctx.db
      .query("mcpProTokens")
      .withIndex("by_userId", (q) => q.eq("userId", args.userId))
      .collect();

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Ensure the entitlement row exists and `validUntil` is in the future before triggering the OAuth flow (await the Dodo webhook / sync).
  2. Confirm the user's plan has `tier >= 1` AND `mcpAccess: true` in the plan catalog (`getFeaturesForPlan`).
  3. If this is a webhook-sync race, retry `issueProMcpToken` after a short delay once the entitlement is written.
  4. For legacy users, run the entitlement backfill so every paying user has a row with merged features.
  5. Surface a clear "Upgrade to Pro" message to the end user; do NOT silently mint a token that will fail downstream.

Example fix

// before — issuing before entitlement is confirmed
await ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });

// after — verify entitlement first, handle the race
const ent = await ctx.runQuery(api.entitlements.getForUser, { userId });
if (!ent || ent.validUntil < Date.now() || ent.features.tier < 1 || !ent.features.mcpAccess) {
  throw new Error("User lacks Pro entitlement with MCP access");
}
await ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });
Defensive patterns

Strategy: validation

Validate before calling

const ent = await convex.query(api.entitlements.getForUser, {});
if (!ent || ent.validUntil < Date.now() || ent.features.tier < 1 || !ent.features.mcpAccess) {
  showUpgradePrompt();
  return;
}

Type guard

function hasProMcpEntitlement(ent: { validUntil: number; features: { tier: number; mcpAccess?: boolean } } | null): boolean {
  return !!ent && ent.validUntil >= Date.now() && ent.features.tier >= 1 && ent.features.mcpAccess === true;
}

Try / catch

// issueProMcpToken is an internalMutation — the catch lives in the action that calls it
try {
  await ctx.runMutation(internal.mcpProTokens.issueProMcpToken, { userId, clientId });
} catch (err) {
  if (err.data === "PRO_REQUIRED") throw new Error("User lacks Pro entitlement with MCP access");
  else throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/a5ac931b4121cbca. Report an issue: GitHub.