koala73/worldmonitor · error · ConvexError

PRO_REQUIRED

PRO_REQUIRED

Error message

Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.

What it means

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.

Solutions

  1. 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.
  2. 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.
  3. In tests and E2E runs: seed a valid entitlement row for the test user's Clerk subject before invoking any alert-rule mutation.
  4. 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.

Example fix

// before
await mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['military'], channels: ['email'] });
// ConvexError: PRO_REQUIRED

// after
import { ConvexError } from 'convex/values';
try {
  await mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['military'], channels: ['email'] });
} catch (err) {
  if (err instanceof ConvexError && (err.data as { code?: string })?.code === 'PRO_REQUIRED') {
    router.push('/upgrade?from=alerts');
    return;
  }
  throw err;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Optional UI pre-gate using your cached subscription state; the server check remains authoritative.
if (!user?.isPro) {
  openUpgradeDialog('Notifications are a PRO feature');
  return;
}
await mutateAPI.alertRules.setAlertRules(args);

Try / catch

import { ConvexError } from 'convex/values';

function isProRequired(err: unknown): boolean {
  return (
    err instanceof ConvexError &&
    typeof err.data === 'object' &&
    err.data !== null &&
    (err.data as { code?: string }).code === 'PRO_REQUIRED'
  );
}

try {
  await mutateAPI.alertRules.setAlertRules(args);
} catch (err) {
  if (isProRequired(err)) { router.push('/upgrade?from=alerts'); return; }
  throw err;
}

Prevention

When it happens

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

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

Related errors


AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21). Data as JSON: /api/errors/976857570efbcf2c. Report an issue: GitHub.

Appendix: source

Thrown at convex/alertRules.ts:49

 *
 * Kept inline (not imported from entitlements.ts) for security-review
 * readability: every alertRules mutation that calls this should be
 * trivially auditable in one file.
 */
async function assertProEntitlement(
  ctx: MutationCtx,
  userId: string,
): Promise<void> {
  const entitlement = await ctx.db
    .query("entitlements")
    .withIndex("by_userId", (q) => q.eq("userId", userId))
    .first();
  const tier =
    entitlement && entitlement.validUntil >= Date.now()
      ? entitlement.features.tier
      : 0;
  if (tier < 1) {
    throw new ConvexError({
      code: "PRO_REQUIRED",
      message:
        "Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.",
    });
  }
}

// Cross-field invariant enforcement for (digestMode, sensitivity).
//
// Tightened rule (2026-04-27): real-time delivery is now reserved for
// `critical`-tier events only. `(realtime, all)` and `(realtime, high)` are
// both forbidden. Anything below `critical` lives in a digest cadence
// (daily / twice_daily / weekly).
//
// Why tighter: even on `(realtime, high)`, `high`-severity events fire
// frequently enough on busy days to overload an inbox (severe weather,
// market moves, geopolitics). Real-time is for "interrupt me NOW" content
// only — i.e. genuinely critical. High events still reach the user, just

View on GitHub (pinned to eeab0a219f)