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
- 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.
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
- 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.
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
- PRO_REQUIRED
- UNAUTHENTICATED
- COMPANY_MONITORING_ACCESS_DENIED
- COMPANY_MONITORING_ACCESS_DENIED
- UNAUTHENTICATED
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/a5ac931b4121cbca.
Report an issue: GitHub.