{"record":{"id":"976857570efbcf2c","repo":"koala73/worldmonitor","slug":"pro-required","errorCode":"PRO_REQUIRED","errorMessage":"Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.","messagePattern":"Notifications are a PRO feature\\. Upgrade to enable real-time and digest alerts\\.","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":49,"sourceCode":" *\n * Kept inline (not imported from entitlements.ts) for security-review\n * readability: every alertRules mutation that calls this should be\n * trivially auditable in one file.\n */\nasync function assertProEntitlement(\n  ctx: MutationCtx,\n  userId: string,\n): Promise<void> {\n  const entitlement = await ctx.db\n    .query(\"entitlements\")\n    .withIndex(\"by_userId\", (q) => q.eq(\"userId\", userId))\n    .first();\n  const tier =\n    entitlement && entitlement.validUntil >= Date.now()\n      ? entitlement.features.tier\n      : 0;\n  if (tier < 1) {\n    throw new ConvexError({\n      code: \"PRO_REQUIRED\",\n      message:\n        \"Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.\",\n    });\n  }\n}\n\n// Cross-field invariant enforcement for (digestMode, sensitivity).\n//\n// Tightened rule (2026-04-27): real-time delivery is now reserved for\n// `critical`-tier events only. `(realtime, all)` and `(realtime, high)` are\n// both forbidden. Anything below `critical` lives in a digest cadence\n// (daily / twice_daily / weekly).\n//\n// Why tighter: even on `(realtime, high)`, `high`-severity events fire\n// frequently enough on busy days to overload an inbox (severe weather,\n// market moves, geopolitics). Real-time is for \"interrupt me NOW\" content\n// only — i.e. genuinely critical. High events still reach the user, just","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/alertRules.ts#L31-L67","documentation":"assertProEntitlement (convex/alertRules.ts:36) is the layer-2 write-path entitlement gate called by every alert-rule mutation (setAlertRules, setDigestSettings, setQuietHours, setNotificationConfigForUser). It reads the user's 'entitlements' row and treats a missing row or validUntil < Date.now() as tier 0 (free). Any tier < 1 throws ConvexError with structured data { code: 'PRO_REQUIRED' } so the client can route to the upgrade flow instead of surfacing a generic 500.","triggerScenarios":"Calling api.alertRules.setAlertRules / setDigestSettings / setQuietHours as a free-tier user; calling the public 'set-notification-config' HTTP action (which forwards to the gated setNotificationConfigForUser) for a user whose entitlement expired (validUntil in the past) or who has no entitlements document; E2E tests that sign in a fresh Clerk user without seeding an entitlement row.","commonSituations":"A patched client or old UI build bypassing the layer-1 paywall (the comment records a 2026-04-28 audit finding 7 of 28 enabled rules belonged to free-tier users); subscriptions that lapsed after card failure or refund (validUntil passes silently); test environments where only Clerk auth was wired up, not entitlements.","solutions":["If the caller is a legitimate end user: catch the ConvexError, inspect data.code === 'PRO_REQUIRED', and route to the upgrade screen — do not retry the mutation.","If the user should be PRO: repair their entitlements document (features.tier >= 1 and validUntil >= Date.now()). An expired row is treated as tier 0 even if tier was 1.","In tests and E2E runs: seed a valid entitlement row for the test user's Clerk subject before invoking any alert-rule mutation.","For trusted operator scripts that must manage free-tier rows, use the intentionally ungated *ForUser internal mutations (setAlertRulesForUser, setQuietHoursForUser) — note setNotificationConfigForUser IS gated and will still throw."],"exampleFix":"// before\nawait mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['military'], channels: ['email'] });\n// ConvexError: PRO_REQUIRED\n\n// after\nimport { ConvexError } from 'convex/values';\ntry {\n  await mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['military'], channels: ['email'] });\n} catch (err) {\n  if (err instanceof ConvexError && (err.data as { code?: string })?.code === 'PRO_REQUIRED') {\n    router.push('/upgrade?from=alerts');\n    return;\n  }\n  throw err;\n}","handlingStrategy":"try-catch","validationCode":"// Optional UI pre-gate using your cached subscription state; the server check remains authoritative.\nif (!user?.isPro) {\n  openUpgradeDialog('Notifications are a PRO feature');\n  return;\n}\nawait mutateAPI.alertRules.setAlertRules(args);","typeGuard":null,"tryCatchPattern":"import { ConvexError } from 'convex/values';\n\nfunction isProRequired(err: unknown): boolean {\n  return (\n    err instanceof ConvexError &&\n    typeof err.data === 'object' &&\n    err.data !== null &&\n    (err.data as { code?: string }).code === 'PRO_REQUIRED'\n  );\n}\n\ntry {\n  await mutateAPI.alertRules.setAlertRules(args);\n} catch (err) {\n  if (isProRequired(err)) { router.push('/upgrade?from=alerts'); return; }\n  throw err;\n}","preventionTips":["Gate the alert-settings UI behind the same tier state the server enforces (tier >= 1 AND validUntil not expired).","Check entitlement freshness, not just the tier field — expired rows behave as tier 0.","Seed entitlements documents for every test user before alert-mutation suites.","Map data.code === 'PRO_REQUIRED' to the upgrade flow; never show it as a generic error or retry it."],"tags":["convex","entitlement","paywall","pro-required","clerk"],"backgroundTag":"feature-requires-upgrade","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}