| /** | |
| * Entitlement enforcement middleware for the Vercel API gateway. | |
| * | |
| * Reads cached entitlements from Redis (raw keys, no deployment prefix) with | |
| * Convex fallback on cache miss. Returns a 403 Response for tier-gated endpoints | |
| * when the user lacks the required tier. | |
| * | |
| * Fail-closed behavior of checkEntitlement(): | |
| * - No userId header on a gated endpoint -> 403 (authentication required) | |
| * - Redis miss + Convex failure -> 403 (unable to verify entitlements) | |
| * - Endpoint not in ENDPOINT_ENTITLEMENTS -> allow (unrestricted) | |
| * | |
| * A lookup that was attempted but produced no answer — Redis/Convex failure, | |
| * Convex 5xx, or a Convex 4xx that means our own credential is wrong — returns a | |
| * verificationUnavailable marker so callers answer with a retryable 503 instead | |
| * of a misleading hard denial. A null means either that no lookup was attempted | |
| * (backend unconfigured) or that Convex confirmed the user has no entitlement. | |
| * The user-key gateway fails closed on null when the backend is configured and | |
| * retains a logged fail-open exception only when lookup is wholly unconfigured. | |
| * | |
| * classifyBillingVerification() is the single decision point for that denial; | |
| * getBillingVerificationDenial() renders it as JSON, and the HTML / OAuth-grant | |
| * / boolean-premium surfaces render the same decision in their own vocabulary | |
| * (#5622). A transient answer is negative-cached in-process for a few seconds so | |
| * a backend outage costs one lookup per user per window, not one per request. | |
| */ | |
| import { getCachedJson, setCachedJson } from './redis'; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| // Single source of truth for the billing-verification status union — imported | |
| // by api/mcp/types.ts, api/mcp/auth.ts, and api/mcp/billing-denial.ts so the | |
| // four surfaces cannot silently drift when a status is added. | |
| export type BillingVerificationStatus = | |
| | 'subscription_lapsed' | |
| | 'renewal_verification_pending' | |
| | 'renewal_verification_failed'; | |
| export interface CachedEntitlements { | |
| planKey: string; | |
| features: { | |
| tier: number; | |
| apiAccess: boolean; | |
| apiRateLimit: number; | |
| maxDashboards: number; | |
| prioritySupport: boolean; | |
| exportFormats: string[]; | |
| /** | |
| * Pro MCP access (plan 2026-05-10-001). Undefined on legacy entitlement | |
| * rows written before the catalog field landed; every consumer | |
| * (gateway HMAC verifier, isCallerPremium, MCP edge handler) treats | |
| * undefined as `false` — fail-closed. The Dodo webhook repopulates | |
| * this on the next subscription event. | |
| */ | |
| mcpAccess?: boolean; | |
| /** | |
| * Per-account daily REST allowance (#3199). The rate-limit layer | |
| * hard-rejects (in enforce mode) at this value (#4635). `-1` = | |
| * unlimited. Unlike `mcpAccess`, consumers treat `undefined` as | |
| * **no daily limit (fail-OPEN)** — a stale/legacy cache must not punish | |
| * a paying customer. NOT added to the cache-staleness gate below for | |
| * that reason (forcing a re-fetch would contradict fail-open). | |
| */ | |
| apiDailyAllowance?: number; | |
| /** | |
| * Data-export entitlement (plan 2026-07-25-001) — the enforcement field | |
| * for CSV/JSON/PDF export. Like `apiDailyAllowance` and unlike | |
| * `mcpAccess`, consumers treat `undefined` on a `tier >= 2` row as | |
| * **entitled (fail-OPEN)**, and deliberately NOT added to the | |
| * cache-staleness gate below — which is exactly why that fail-open is | |
| * permanent rather than a migration window. | |
| */ | |
| dataExport?: boolean; | |
| /** | |
| * Catalog plan limits, mirrored verbatim from `PlanFeatures.planLimits` | |
| * (convex/config/productCatalog.ts). Optional because legacy rows predate | |
| * it and because the Convex read path only merges what the catalog holds. | |
| * `null` on a member means **unlimited**; a MISSING member (or a missing | |
| * `planLimits` altogether) means unknown, and consumers resolve unknown | |
| * toward cost protection — never toward the higher allowance. The MCP | |
| * daily quota (plan 2026-07-25-001 U3) is the first consumer. | |
| */ | |
| planLimits?: { | |
| apiRequestsPerDay?: number | null; | |
| apiBurstRequestsPerMinute?: number | null; | |
| mcpCallsPerDay?: number | null; | |
| mcpBurstRequestsPerMinute?: number | null; | |
| }; | |
| }; | |
| validUntil: number; | |
| billingStatus?: BillingVerificationStatus; | |
| retryAfterSeconds?: number; | |
| renewalVerificationFreshness?: { | |
| status: 'not_applicable'; | |
| checkedAt: number; | |
| }; | |
| // Synthesized by getEntitlements() when a lookup was ATTEMPTED and produced | |
| // no answer about this user: a fetch abort at the 3s budget (which the #4770 | |
| // on-demand provider re-check can consume), a network error, a Convex 5xx, or | |
| // a Convex 4xx (#5619 — our own shared secret or contract is wrong, which | |
| // says nothing about the caller's plan). A free-shaped, deny-side value that | |
| // getBillingVerificationDenial turns into the retryable | |
| // entitlement_verification_unavailable 503 instead of a hard "upgrade | |
| // required"/401. Never originates from Convex and is never written to the | |
| // Redis cache (it IS held for a few seconds in the in-process negative cache | |
| // below, which bounds outage amplification without making the state durable | |
| // or visible to another isolate). | |
| // | |
| // A null return therefore means one of exactly two things: the backend is | |
| // unconfigured so no lookup was attempted (server/gateway.ts detects that | |
| // with isEntitlementBackendConfigured() and keeps its wm_-key fail-open | |
| // exception), or Convex answered and this user has no entitlement row — a | |
| // confirmed free account, which is the one state that may honestly upsell. | |
| verificationUnavailable?: true; | |
| } | |
| export interface EntitlementCheckResult { | |
| response: Response | null; | |
| entitlements: CachedEntitlements | null; | |
| } | |
| export interface EntitlementCheckOptions { | |
| clerkRole?: 'free' | 'pro' | null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Endpoint-to-tier map (replaces PREMIUM_RPC_PATHS) | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Maps API endpoints to the minimum tier required for access. | |
| * Tier hierarchy: 0=free, 1=pro, 2=api, 3=enterprise. | |
| * | |
| * Adding a new gated endpoint = adding one line to this map. | |
| * Endpoints NOT in this map are unrestricted. | |
| * | |
| * Stock-analysis endpoints sit at tier 1 (Pro) — the productCatalog markets | |
| * "AI stock analysis & backtesting" as a Pro feature, and these paths are | |
| * also in PREMIUM_RPC_PATHS where the legacy bearer gate accepts tier >= 1. | |
| * Tier-2 here would have made the new gate stricter than the legacy one and | |
| * 403'd real Pro subscribers calling via Clerk session (no tester key). | |
| */ | |
| const ENDPOINT_ENTITLEMENTS: Record<string, number> = { | |
| '/api/forecast/v1/trigger-simulation': 1, | |
| '/api/intelligence/v1/classify-event': 1, | |
| '/api/intelligence/v1/get-country-intel-brief': 1, | |
| '/api/intelligence/v1/search-intel-history': 1, | |
| '/api/intelligence/v1/get-intel-timeline': 1, | |
| '/api/intelligence/v1/get-similar-events': 1, | |
| '/api/market/v1/analyze-stock': 1, | |
| '/api/market/v1/get-stock-analysis-history': 1, | |
| '/api/market/v1/backtest-stock': 1, | |
| '/api/market/v1/list-stored-stock-backtests': 1, | |
| '/api/economic/v1/list-global-tenders': 1, | |
| '/api/sanctions/v1/list-sanctions-pressure': 1, | |
| '/api/scenario/v1/run-scenario': 1, | |
| '/api/scenario/v1/get-scenario-status': 1, | |
| '/api/supply-chain/v1/get-country-chokepoint-index': 1, | |
| '/api/supply-chain/v1/get-bypass-options': 1, | |
| '/api/supply-chain/v1/get-country-cost-shock': 1, | |
| '/api/supply-chain/v1/get-route-explorer-lane': 1, | |
| '/api/supply-chain/v1/get-route-impact': 1, | |
| '/api/supply-chain/v1/get-country-products': 1, | |
| '/api/supply-chain/v1/get-multi-sector-cost-shock': 1, | |
| '/api/supply-chain/v1/get-sector-dependency': 1, | |
| '/api/trade/v1/list-comtrade-flows': 1, | |
| '/api/trade/v1/get-tariff-trends': 1, | |
| }; | |
| const CONVEX_INTERNAL_ENTITLEMENTS_PATH = '/api/internal-entitlements'; | |
| let _didWarnMissingConvexSharedSecret = false; | |
| let _didWarnMissingConvexSiteUrl = false; | |
| function getConvexSharedSecret(): string { | |
| const secret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ''; | |
| if (!secret && !_didWarnMissingConvexSharedSecret) { | |
| _didWarnMissingConvexSharedSecret = true; | |
| console.warn('[entitlement-check] CONVEX_SERVER_SHARED_SECRET not set; Convex fallback disabled'); | |
| } | |
| return secret; | |
| } | |
| /** | |
| * Warn once when CONVEX_SITE_URL is missing. Its sibling above covered only the | |
| * shared secret, so a deploy missing ONLY the site URL disabled the Convex | |
| * fallback with no signal from this module. The warning keeps that deployment | |
| * defect visible alongside the gateway's explicit unconfigured-backend log. | |
| */ | |
| function getConvexSiteUrl(): string { | |
| const siteUrl = process.env.CONVEX_SITE_URL ?? ''; | |
| if (!siteUrl && !_didWarnMissingConvexSiteUrl) { | |
| _didWarnMissingConvexSiteUrl = true; | |
| console.warn('[entitlement-check] CONVEX_SITE_URL not set; Convex fallback disabled'); | |
| } | |
| return siteUrl; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Request coalescing (P1-6: Cache stampede mitigation) | |
| // --------------------------------------------------------------------------- | |
| const _inFlight = new Map<string, Promise<CachedEntitlements | null>>(); | |
| // --------------------------------------------------------------------------- | |
| // Transient-failure negative cache (#5622) | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * How long a synthesized verificationUnavailable answer is reused for the same | |
| * user without re-attempting the backend. | |
| * | |
| * The problem this bounds: a transient answer was never cached anywhere. | |
| * `unavailableEntitlements()` is synthesized in-process and deliberately never | |
| * written to Redis, so during a Convex outage EVERY request re-ran the lookup | |
| * and paid the full 3s fetch budget before producing the identical denial. | |
| * Request coalescing (`_inFlight` above) only collapses *concurrent* requests; | |
| * a client politely retrying in sequence amplified the outage instead. Bounding | |
| * the tier-0 marker to 60s in #5600 made this path more reachable. | |
| * | |
| * The value is load-bearing, not arbitrary: it MUST stay strictly below the | |
| * `Retry-After` that getBillingVerificationDenial advertises for this state | |
| * (clampRetryAfterSeconds's 5s default, since the synthesized marker carries no | |
| * retryAfterSeconds). Otherwise a client that correctly honors `Retry-After` | |
| * would land back inside the window and be served the cached failure — turning | |
| * a bounded outage into one that outlives it. The negative-cache test pins that | |
| * inequality so raising this constant past the advertised delay fails. | |
| */ | |
| const UNAVAILABLE_NEGATIVE_CACHE_TTL_MS = 3_000; | |
| /** | |
| * Cap on distinct users held in the negative cache. A fleet-wide Convex outage | |
| * would otherwise grow this map with one entry per active user for the life of | |
| * the isolate; entries are ~40 bytes, so the cap is about memory hygiene rather | |
| * than a real ceiling. Eviction drops expired entries first, then the oldest | |
| * insertions (Map preserves insertion order), so overflow degrades to the | |
| * pre-#5622 behavior (an extra lookup) rather than to unbounded growth. | |
| */ | |
| const UNAVAILABLE_NEGATIVE_CACHE_MAX_ENTRIES = 1_000; | |
| /** | |
| * userId -> when the cached transient failure expires, plus any upstream-supplied | |
| * cooldown that produced it. | |
| * | |
| * `retryAfterSeconds` rides along because the cache HIT re-synthesizes the | |
| * marker rather than storing it: without this, a 429's own Retry-After would be | |
| * honored on the first response and silently downgraded to the generic default | |
| * for every hit inside the window — sending clients back at the upstream sooner | |
| * than it asked, which is the amplification the header exists to prevent. | |
| */ | |
| const _unavailableUntil = new Map<string, { expiresAt: number; retryAfterSeconds?: number }>(); | |
| function rememberVerificationUnavailable(userId: string, retryAfterSeconds?: number): void { | |
| const now = Date.now(); | |
| if (_unavailableUntil.size >= UNAVAILABLE_NEGATIVE_CACHE_MAX_ENTRIES) { | |
| for (const [key, entry] of _unavailableUntil) { | |
| if (entry.expiresAt <= now) _unavailableUntil.delete(key); | |
| } | |
| while (_unavailableUntil.size >= UNAVAILABLE_NEGATIVE_CACHE_MAX_ENTRIES) { | |
| const oldest = _unavailableUntil.keys().next(); | |
| if (oldest.done) break; | |
| _unavailableUntil.delete(oldest.value); | |
| } | |
| } | |
| _unavailableUntil.set(userId, { | |
| expiresAt: now + UNAVAILABLE_NEGATIVE_CACHE_TTL_MS, | |
| retryAfterSeconds, | |
| }); | |
| } | |
| /** | |
| * Module state is per-isolate and survives between tests in the same file. | |
| * Exposed so a test can assert the negative cache both hits AND expires without | |
| * depending on which userIds earlier tests happened to poison. | |
| */ | |
| export function __resetEntitlementNegativeCacheForTests(): void { | |
| _unavailableUntil.clear(); | |
| } | |
| /** Test-only view of the advertised-retry invariant the TTL above depends on. */ | |
| export const __negativeCacheTtlMsForTests = UNAVAILABLE_NEGATIVE_CACHE_TTL_MS; | |
| /** | |
| * Test-only view of the cap and the live entry count, so the eviction branch can | |
| * be driven past its threshold and asserted bounded. Without these the cap is | |
| * unreachable from a test — the branch only fires above 1000 distinct | |
| * concurrently-failing users, which is exactly the fleet-wide-outage case whose | |
| * memory behavior the cap exists to bound. | |
| */ | |
| export const __negativeCacheMaxEntriesForTests = UNAVAILABLE_NEGATIVE_CACHE_MAX_ENTRIES; | |
| export function __negativeCacheSizeForTests(): number { | |
| return _unavailableUntil.size; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Environment-aware Redis key prefix (P2-3) | |
| // --------------------------------------------------------------------------- | |
| const ENV_PREFIX = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live' : 'test'; | |
| // Cache TTL: 15 min — short enough that subscription expiry is reflected promptly (P2-5) | |
| const ENTITLEMENT_CACHE_TTL_SECONDS = 900; | |
| // Hard-403 markers are served for their FULL Redis TTL with no Convex | |
| // fallback, so this TTL is also the worst-case wrongful-denial window when a | |
| // stale marker write races a renewal webhook. Keep it short: the row-level | |
| // 5-min lapsed cooldown (billing.ts) already suppresses Dodo calls, so the | |
| // only cost of a short marker is ~1 cheap Convex round-trip per minute per | |
| // actively-retrying lapsed user. | |
| const LAPSED_BILLING_MARKER_TTL_SECONDS = 60; | |
| // Convex stamps the not-applicable marker ONLY for a user with zero | |
| // subscription rows (convex/payments/billing.ts | |
| // claimRecentlyStaleSubscriptionForVerification: no billing history -> | |
| // not_applicable, any history -> lapsed). "No history" is therefore not the | |
| // stable state the previous 900s assumed: it is also what a buyer looks like | |
| // in the seconds between checkout return and the Dodo webhook landing. | |
| // | |
| // The "syncEntitlementCache always overwrites this key" invariant does NOT | |
| // close that window — the read path is not atomic. A request that misses cache | |
| // at t0 can have its stale free+marker payload land in Redis AFTER the | |
| // webhook's Pro write at t0+ε, re-poisoning the key for the marker's full TTL | |
| // (#5600: 15 min of 403s on every tier-gated endpoint for a paying customer, | |
| // reproduced live 2026-07-25). Bounding the marker bounds that worst case. | |
| // | |
| // Be precise about WHICH window this bounds, because it is not the whole one a | |
| // buyer experiences: | |
| // | |
| // wrongful-403 window = dodo_webhook_latency + min(this TTL, resync residual) | |
| // | |
| // Only the second term is bounded here. convex/http.ts re-stamps a fresh | |
| // not_applicable marker on every fallback for as long as the user has zero | |
| // subscription rows, so an expiry just mints another window — the buyer waits out | |
| // the webhook either way, just re-checking every 60s instead of every 900s. | |
| // And the second term is usually already covered: convex/payments/ | |
| // subscriptionHelpers.ts schedules resyncEntitlementCacheFromDb at | |
| // ENTITLEMENT_CACHE_RESYNC_DELAY_MS (15s), a quarter of this TTL, which corrects a | |
| // poisoned key first in the common case. This TTL is the bound for when that | |
| // re-sync also loses the race or throws (it is fire-and-forget, no retry). Raising | |
| // that delay past this TTL silently promotes this constant to sole defense. | |
| // | |
| // The cost is one extra Convex round-trip per minute per actively-requesting | |
| // never-subscribed user — still far cheaper than pre-#4770, where a tier-0 | |
| // answer was never served from cache at all (free rows carry validUntil: 0, | |
| // so the ordinary freshness gate below always fell through). | |
| const NOT_APPLICABLE_VERIFICATION_TTL_SECONDS = 60; | |
| /** | |
| * True when the Convex entitlement backend is reachable in principle. Callers | |
| * that fail closed on a null entitlement use this to distinguish a genuine | |
| * verification failure (fail closed) from a deploy misconfiguration where no | |
| * lookup could ever succeed (fail open + page). | |
| */ | |
| export function isEntitlementBackendConfigured(): boolean { | |
| return Boolean(process.env.CONVEX_SITE_URL && getConvexSharedSecret()); | |
| } | |
| function clampRetryAfterSeconds(raw: number | undefined): number { | |
| return Number.isFinite(raw) | |
| ? Math.max(1, Math.min(60, Math.ceil(raw!))) | |
| : 5; | |
| } | |
| function isBillingVerificationStatus( | |
| value: unknown, | |
| ): value is NonNullable<CachedEntitlements['billingStatus']> { | |
| return value === 'subscription_lapsed' | |
| || value === 'renewal_verification_pending' | |
| || value === 'renewal_verification_failed'; | |
| } | |
| function billingMarkerTtlSeconds(entitlements: CachedEntitlements): number | null { | |
| if (!isBillingVerificationStatus(entitlements.billingStatus)) return null; | |
| if (entitlements.billingStatus === 'subscription_lapsed') { | |
| return LAPSED_BILLING_MARKER_TTL_SECONDS; | |
| } | |
| return clampRetryAfterSeconds(entitlements.retryAfterSeconds); | |
| } | |
| function notApplicableVerificationTtlSeconds( | |
| entitlements: CachedEntitlements, | |
| ): number | null { | |
| const marker = entitlements.renewalVerificationFreshness; | |
| if ( | |
| marker?.status !== 'not_applicable' | |
| || !Number.isFinite(marker.checkedAt) | |
| ) { | |
| return null; | |
| } | |
| const remainingMs = marker.checkedAt | |
| + NOT_APPLICABLE_VERIFICATION_TTL_SECONDS * 1_000 | |
| - Date.now(); | |
| return remainingMs > 0 | |
| ? Math.max(1, Math.min( | |
| NOT_APPLICABLE_VERIFICATION_TTL_SECONDS, | |
| Math.ceil(remainingMs / 1_000), | |
| )) | |
| : null; | |
| } | |
| function entitlementMarkerTtlSeconds(entitlements: CachedEntitlements): number | null { | |
| return billingMarkerTtlSeconds(entitlements) | |
| ?? notApplicableVerificationTtlSeconds(entitlements); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Public API | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Returns the minimum tier required for a given endpoint pathname. | |
| * Returns null if the endpoint is unrestricted (not in the map). | |
| */ | |
| export function getRequiredTier(pathname: string): number | null { | |
| return ENDPOINT_ENTITLEMENTS[pathname] ?? null; | |
| } | |
| /** | |
| * Every tier-gated pathname, as a set. | |
| * | |
| * Exported so tests/premium-paths-guard.test.mts can enforce that this map | |
| * stays a subset of PREMIUM_RPC_PATHS — an invariant src/services/premium-fetch.ts | |
| * documents and depends on, but which nothing checked before #5674. The gateway | |
| * sets `forceKey` on tier-gated routes, and forceKey rejects a valid anonymous | |
| * wms_ token with 401, so a route added here but not there 401s every anonymous | |
| * browser call and drives the wm-session interceptor into its mint→replay→ | |
| * 15-minute-blackout loop. The map itself stays private so it keeps its single | |
| * point of edit. | |
| */ | |
| export const TIER_GATED_PATHS: ReadonlySet<string> = new Set(Object.keys(ENDPOINT_ENTITLEMENTS)); | |
| /** | |
| * Fetches entitlements for a user. Tries Redis cache first (raw key), | |
| * then falls back to ConvexHttpClient query on cache miss. | |
| * | |
| * Returns null on any failure (fail-closed: caller must treat null as no entitlements). | |
| * | |
| * Uses request coalescing to prevent cache stampede: concurrent requests for | |
| * the same userId share a single in-flight promise. | |
| */ | |
| export async function getEntitlements(userId: string): Promise<CachedEntitlements | null> { | |
| // Negative cache first: a transient failure recorded moments ago is reused | |
| // rather than re-paying the backend's 3s budget. Only the synthesized | |
| // verificationUnavailable answer is cached here — never a confirmed row and | |
| // never a fail-closed null, both of which have their own (Redis) cache policy. | |
| const unavailable = _unavailableUntil.get(userId); | |
| if (unavailable !== undefined) { | |
| if (unavailable.expiresAt > Date.now()) { | |
| return unavailableEntitlements(unavailable.retryAfterSeconds); | |
| } | |
| _unavailableUntil.delete(userId); | |
| } | |
| const existing = _inFlight.get(userId); | |
| if (existing) return existing; | |
| const promise = _getEntitlementsImpl(userId); | |
| _inFlight.set(userId, promise); | |
| try { | |
| const result = await promise; | |
| if (result?.verificationUnavailable) { | |
| rememberVerificationUnavailable(userId, result.retryAfterSeconds); | |
| } | |
| return result; | |
| } finally { | |
| _inFlight.delete(userId); | |
| } | |
| } | |
| // Free-shaped deny-side value for transient lookup failures. Grants nothing | |
| // (tier 0, no apiAccess/mcpAccess, validUntil 0); its only power is steering | |
| // the gates to the retryable 503 via getBillingVerificationDenial. | |
| // | |
| // `retryAfterSeconds` is optional and only supplied when the upstream told us | |
| // how long to wait (a 429's own Retry-After). Omitted, the denial constructors | |
| // fall back to the generic clamp default, which is the behavior every other | |
| // failure mode here wants. | |
| function unavailableEntitlements(retryAfterSeconds?: number): CachedEntitlements { | |
| return { | |
| planKey: 'free', | |
| features: { | |
| tier: 0, | |
| apiAccess: false, | |
| apiRateLimit: 0, | |
| maxDashboards: 3, | |
| prioritySupport: false, | |
| exportFormats: ['csv'], | |
| mcpAccess: false, | |
| }, | |
| validUntil: 0, | |
| verificationUnavailable: true, | |
| ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | |
| }; | |
| } | |
| /** | |
| * Parse an HTTP `Retry-After` header into seconds. | |
| * | |
| * Only the delta-seconds form is honored. The HTTP-date form is legal but | |
| * Convex does not emit it, and guessing at clock skew to support it would be | |
| * worse than falling back to the caller's default. | |
| */ | |
| function parseRetryAfterSeconds(header: string | null): number | undefined { | |
| if (!header) return undefined; | |
| const seconds = Number(header.trim()); | |
| return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined; | |
| } | |
| async function _getEntitlementsImpl(userId: string): Promise<CachedEntitlements | null> { | |
| try { | |
| // Redis cache check (raw=true: entitlements use user-scoped keys, no deployment prefix) | |
| const cached = await getCachedJson(`entitlements:${ENV_PREFIX}:${userId}`, true); | |
| if (cached && typeof cached === 'object') { | |
| const ent = cached as CachedEntitlements; | |
| // Verification markers have their own short Redis TTL. Serve them even | |
| // though validUntil is expired so cooldown requests stop at Redis instead | |
| // of repeating the Convex action/claim chain. | |
| if (entitlementMarkerTtlSeconds(ent) !== null) return ent; | |
| // Only use cached data if it hasn't expired AND has the post-U10 shape. | |
| // | |
| // Legacy cache entries written before plan 2026-05-10-001 U10 lack the | |
| // `features.mcpAccess` field. The Convex read path read-time-merges | |
| // catalog defaults (convex/entitlements.ts:50), but bare-cache reads | |
| // bypass that merge — paying users with hot pre-deploy cache entries | |
| // would see `mcpAccess !== true` at the grant/MCP gates and get | |
| // blocked for up to 15 min until the cache expires. Treating | |
| // missing-field cache entries as stale falls through to Convex, | |
| // which returns the merged shape and rewrites the cache with the | |
| // post-U10 layout. Self-healing, bounded to one extra Convex | |
| // round-trip per affected user during the migration window. | |
| // Reviewer round-2 P2 (cache layer). | |
| if ( | |
| ent.validUntil >= Date.now() && | |
| typeof (ent.features as { mcpAccess?: boolean }).mcpAccess === 'boolean' | |
| ) { | |
| return ent; | |
| } | |
| // Expired OR legacy shape -- fall through to Convex. | |
| } | |
| // Convex fallback on cache miss or expired cache | |
| const convexSiteUrl = getConvexSiteUrl(); | |
| const convexSharedSecret = getConvexSharedSecret(); | |
| // MISCONFIGURATION HAZARD: a deploy missing CONVEX_SITE_URL or | |
| // CONVEX_SERVER_SHARED_SECRET returns null for every user on every request. | |
| // The gateway recognizes that configuration state and logs before using its | |
| // explicit fail-open deploy-defect exception; other entitlement gates remain | |
| // fail closed. Warn once per variable here so neither missing value is silent. | |
| if (!convexSiteUrl || !convexSharedSecret) return null; | |
| const response = await fetch(`${convexSiteUrl}${CONVEX_INTERNAL_ENTITLEMENTS_PATH}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'User-Agent': 'worldmonitor-gateway/1.0', | |
| 'x-convex-shared-secret': convexSharedSecret, | |
| }, | |
| body: JSON.stringify({ userId }), | |
| signal: AbortSignal.timeout(3_000), | |
| }); | |
| if (!response.ok) { | |
| // Neither a 5xx (Convex/platform blip) nor a 4xx (bad shared secret, | |
| // contract rejection) produced an answer about this user, so neither may | |
| // reach the wire as one. | |
| // | |
| // #5661 split them — 4xx returned the fail-closed null on the reasoning | |
| // that a deploy defect is not transient. True, but "not transient" and | |
| // "is a verdict about this account's plan" are different axes, and the | |
| // gates only read the second: a null renders as `pro_required` / | |
| // INSUFFICIENT_TIER, i.e. an upsell shown to a paying customer because | |
| // OUR credential is wrong (#5619, the #5600 failure mode). The marker | |
| // denies exactly as hard — tier 0, nothing granted — and only changes the | |
| // wording to the retryable contract, which is already what | |
| // server/gateway.ts answers for this state on wm_-key traffic. A client | |
| // that keeps retrying spends its transient budget and lands on `give_up`: | |
| // still terminal, still not an upsell. | |
| // | |
| // A 429 is the one status that tells us how long to wait. Re-advertising | |
| // the generic 5s default would send clients back inside the upstream's | |
| // own cooldown and amplify the throttling we were just asked to ease, so | |
| // its Retry-After rides along on the marker. | |
| return unavailableEntitlements( | |
| response.status === 429 | |
| ? parseRetryAfterSeconds(response.headers.get('Retry-After')) | |
| : undefined, | |
| ); | |
| } | |
| const result = await response.json() as CachedEntitlements | null; | |
| if (result) { | |
| // Populate Redis cache for subsequent requests (15-min TTL, raw key). | |
| // | |
| // Cache-write failures must NOT collapse "entitlement confirmed by Convex" | |
| // into the null-means-no-entitlement return. Today setCachedJson swallows | |
| // its own Upstash errors via an internal try/catch (server/_shared/redis.ts), | |
| // but that contract is fragile — the tauri-sidecar dynamic import path at | |
| // redis.ts:142-146 is OUTSIDE the inner try/catch, and any future code | |
| // motion could let other errors propagate. Wrap explicitly here so the | |
| // property "Convex said yes ⇒ caller sees yes" is local and load-bearing. | |
| // Without this, an Upstash hiccup would 403 every paying customer on the | |
| // very call paths this file gates — the same shape PR #3505 fixed for the | |
| // Clerk-only-no-Convex outlier in api/widget-agent.ts. | |
| try { | |
| await setCachedJson( | |
| `entitlements:${ENV_PREFIX}:${userId}`, | |
| result, | |
| entitlementMarkerTtlSeconds(result) ?? ENTITLEMENT_CACHE_TTL_SECONDS, | |
| true, | |
| ); | |
| } catch (cacheErr) { | |
| console.warn('[entitlement-check] cache write failed (non-fatal):', cacheErr instanceof Error ? cacheErr.message : String(cacheErr)); | |
| } | |
| return result as CachedEntitlements; | |
| } | |
| return null; | |
| } catch (err) { | |
| // Still fail-closed — nothing is granted — but a TRANSIENT failure | |
| // (timeout/abort, network, a throwing cache read) is distinguishable from | |
| // "no entitlement": return the verificationUnavailable marker so every | |
| // gate answers with the retryable entitlement_verification_unavailable | |
| // 503 (Retry-After) instead of a misleading hard 403/401. Without this, | |
| // the on-demand provider re-check (#4770) overrunning the 3s fetch budget | |
| // reproduced exactly the hard-denial the rework exists to eliminate. | |
| console.warn('[entitlement-check] getEntitlements failed:', err instanceof Error ? err.message : String(err)); | |
| return unavailableEntitlements(); | |
| } | |
| } | |
| /** Entitlement fields the billing-verification decision reads. */ | |
| export type BillingVerificationInput = Pick< | |
| CachedEntitlements, | |
| 'billingStatus' | 'retryAfterSeconds' | 'verificationUnavailable' | |
| >; | |
| /** Wire code for a billing-verification denial, mirrored into `X-Billing-Verification`. */ | |
| export type BillingVerificationCode = | |
| | BillingVerificationStatus | |
| | 'entitlement_verification_unavailable'; | |
| export function isBillingVerificationCode( | |
| value: unknown, | |
| ): value is BillingVerificationCode { | |
| return value === 'entitlement_verification_unavailable' | |
| || isBillingVerificationStatus(value); | |
| } | |
| export interface BillingVerificationDenial { | |
| /** | |
| * False ONLY for a lapse the provider confirmed. Everything else in this | |
| * union is a statement about the *verification*, not the subscription, so a | |
| * caller that renders it as terminal reproduces #5600. | |
| */ | |
| retryable: boolean; | |
| code: BillingVerificationCode; | |
| /** Seconds to wait before retrying. 0 for a terminal denial. */ | |
| retryAfterSeconds: number; | |
| /** Wire `error` string for JSON surfaces. */ | |
| message: string; | |
| /** HTTP status the JSON surfaces use: 503 when retryable, 403 when terminal. */ | |
| status: 403 | 503; | |
| } | |
| /** | |
| * The "we could not verify" denial, built in ONE place. | |
| * | |
| * Two situations produce it and they must not drift apart on the wire: the | |
| * synthesized `verificationUnavailable` marker below, and a caller that already | |
| * knows no lookup could have answered — `getEntitlements` returns null WITHOUT | |
| * attempting one when the backend is unconfigured, so an absent row there is a | |
| * deploy defect rather than a verdict about the account (#5619). | |
| */ | |
| export function unverifiableEntitlementDenial( | |
| retryAfterSeconds?: number, | |
| ): BillingVerificationDenial { | |
| return { | |
| retryable: true, | |
| code: 'entitlement_verification_unavailable', | |
| retryAfterSeconds: clampRetryAfterSeconds(retryAfterSeconds), | |
| message: 'Unable to verify API access', | |
| status: 503, | |
| }; | |
| } | |
| /** | |
| * The billing-verification decision, as a pure predicate over an entitlement | |
| * row — no Response, no headers, no transport. | |
| * | |
| * Extracted from getBillingVerificationDenial (#5622) because three consumers | |
| * cannot use a `Response`: `api/oauth/authorize-pro.ts` renders HTML, | |
| * `api/internal/mcp-grant-{mint,context}.ts` own an `INSUFFICIENT_TIER`-style | |
| * vocabulary inside an OAuth handshake, and `server/_shared/premium-check.ts` | |
| * answers with a boolean/identity. Before this existed each of them flattened | |
| * an *unverifiable* entitlement into a hard denial, which is exactly the #5600 | |
| * failure mode the shared contract was built to remove. | |
| * | |
| * Keep this the single decision point: getBillingVerificationDenial below is a | |
| * thin renderer over it, so a new status cannot reach the JSON surfaces and | |
| * silently miss the HTML/handshake ones. | |
| */ | |
| export function classifyBillingVerification( | |
| entitlements: BillingVerificationInput | null | undefined, | |
| ): BillingVerificationDenial | null { | |
| if (entitlements?.verificationUnavailable) { | |
| // Lookup failure: same wire contract as server/gateway.ts's wm_-key | |
| // null-entitlement branch (docs/usage-errors.mdx). | |
| return unverifiableEntitlementDenial(entitlements.retryAfterSeconds); | |
| } | |
| const status = entitlements?.billingStatus; | |
| if (!isBillingVerificationStatus(status)) return null; | |
| if (status === 'subscription_lapsed') { | |
| // The ONLY terminal member: the provider confirmed coverage ended, so | |
| // retrying cannot flip it (tests/premium-denial.test.mts pins the same | |
| // reading on the client side). | |
| return { | |
| retryable: false, | |
| code: status, | |
| retryAfterSeconds: 0, | |
| message: 'Subscription lapsed', | |
| status: 403, | |
| }; | |
| } | |
| return { | |
| retryable: true, | |
| code: status, | |
| retryAfterSeconds: clampRetryAfterSeconds(entitlements?.retryAfterSeconds), | |
| message: status === 'renewal_verification_pending' | |
| ? 'Renewal verification pending' | |
| : 'Renewal verification failed', | |
| status: 503, | |
| }; | |
| } | |
| /** | |
| * Turns Convex's billing-verification metadata into the shared gateway denial | |
| * contract. Callers use this before their ordinary tier/feature checks so a | |
| * provider outage is never flattened into a misleading "upgrade required". | |
| * | |
| * JSON surfaces only. Non-JSON consumers call classifyBillingVerification() | |
| * above and render the decision in their own vocabulary. | |
| */ | |
| export function getBillingVerificationDenial( | |
| entitlements: BillingVerificationInput | null | undefined, | |
| corsHeaders: Record<string, string>, | |
| requiredTier?: number, | |
| ): Response | null { | |
| const denial = classifyBillingVerification(entitlements); | |
| return denial ? renderBillingVerificationDenial(denial, corsHeaders, requiredTier) : null; | |
| } | |
| /** | |
| * Renders an ALREADY-classified denial as the JSON wire contract. | |
| * | |
| * Split out from getBillingVerificationDenial for callers that classified | |
| * earlier and carry the decision with them — `server/_shared/premium-check.ts` | |
| * attaches it to the denied identity, and api/chat-analyst.ts renders that. | |
| * Before this existed, that route hand-built `{ verificationUnavailable: true }` | |
| * to re-enter the classifier, which collapsed all four states into one. | |
| */ | |
| export function renderBillingVerificationDenial( | |
| denial: BillingVerificationDenial, | |
| corsHeaders: Record<string, string>, | |
| requiredTier?: number, | |
| ): Response { | |
| return new Response( | |
| JSON.stringify({ | |
| error: denial.message, | |
| code: denial.code, | |
| ...(requiredTier == null ? {} : { requiredTier }), | |
| }), | |
| { | |
| status: denial.status, | |
| headers: { | |
| // corsHeaders FIRST: the contract headers below are this function's own | |
| // output and must win. The pre-#5622 version was inconsistent about it | |
| // (a corsHeaders map could clobber X-Billing-Verification but not | |
| // Retry-After); no cors helper in the repo emits either name, so this is | |
| // inert today and pinned by test so it stays that way. | |
| ...corsHeaders, | |
| 'Content-Type': 'application/json', | |
| 'Cache-Control': 'no-store', | |
| 'X-Billing-Verification': denial.code, | |
| // Terminal denials carry no Retry-After — advertising one would invite | |
| // a lapsed subscriber into an infinite retry instead of a resubscribe. | |
| ...(denial.retryable ? { 'Retry-After': String(denial.retryAfterSeconds) } : {}), | |
| }, | |
| }, | |
| ); | |
| } | |
| /** | |
| * Checks whether the current request is allowed based on tier entitlements. | |
| * | |
| * Returns: | |
| * - null if the request is allowed (unrestricted endpoint or sufficient tier) | |
| * - a 403 Response if the user is unauthenticated, entitlements cannot be verified, | |
| * or the user's tier is below the required tier (fail-closed) | |
| */ | |
| export async function checkEntitlement( | |
| userId: string | null, | |
| pathname: string, | |
| corsHeaders: Record<string, string>, | |
| options: EntitlementCheckOptions = {}, | |
| ): Promise<Response | null> { | |
| const result = await checkEntitlementDetailed(userId, pathname, corsHeaders, options); | |
| return result.response; | |
| } | |
| /** | |
| * Same authorization decision as checkEntitlement(), plus the resolved | |
| * entitlement row when one was available. Gateway telemetry uses this so | |
| * allow/deny events reflect the exact plan/tier that drove the decision. | |
| */ | |
| export async function checkEntitlementDetailed( | |
| userId: string | null, | |
| pathname: string, | |
| corsHeaders: Record<string, string>, | |
| options: EntitlementCheckOptions = {}, | |
| ): Promise<EntitlementCheckResult> { | |
| const requiredTier = getRequiredTier(pathname); | |
| if (requiredTier === null) { | |
| // Unrestricted endpoint -- no check needed | |
| return { response: null, entitlements: null }; | |
| } | |
| if (!userId) { | |
| return { | |
| response: new Response( | |
| JSON.stringify({ error: 'Authentication required', requiredTier }), | |
| { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } }, | |
| ), | |
| entitlements: null, | |
| }; | |
| } | |
| // Preserve the legacy Pro bearer contract for tier-1 gates. Complimentary, | |
| // tester, and legacy Clerk-role grants can have no Convex entitlement row, | |
| // while the frontend still unlocks Pro panels for role='pro'. | |
| if (options.clerkRole === 'pro' && requiredTier <= 1) { | |
| return { response: null, entitlements: null }; | |
| } | |
| const ent = await getEntitlements(userId); | |
| if (!ent) { | |
| // An absent row is a verdict about the account only when a lookup could | |
| // actually run. With the backend unconfigured getEntitlements returns null | |
| // BEFORE attempting one — for everyone, paying customers included — so the | |
| // hard 403 below would tell every subscriber their entitlement could not be | |
| // verified because of our own deploy defect. This gate is reached from | |
| // server/gateway.ts on every tier-gated session request, which makes it the | |
| // widest surface of the #5619 asymmetry (#5600 is the precedent). | |
| if (!isEntitlementBackendConfigured()) { | |
| return { | |
| response: renderBillingVerificationDenial( | |
| unverifiableEntitlementDenial(), | |
| corsHeaders, | |
| requiredTier, | |
| ), | |
| entitlements: null, | |
| }; | |
| } | |
| // Fail-closed: unable to verify entitlements -> block the request | |
| return { | |
| response: new Response( | |
| JSON.stringify({ error: 'Unable to verify entitlements', requiredTier }), | |
| { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } }, | |
| ), | |
| entitlements: null, | |
| }; | |
| } | |
| // A stronger recently-stale subscription can be under verification while a | |
| // lower plan still provides current, known-good coverage. Let that fallback | |
| // authorize requests within its tier; the billing marker remains relevant | |
| // only to capabilities above the fallback. | |
| if ( | |
| ent.features.tier >= requiredTier && | |
| ent.validUntil >= Date.now() | |
| ) { | |
| return { response: null, entitlements: ent }; | |
| } | |
| const billingDenial = getBillingVerificationDenial(ent, corsHeaders, requiredTier); | |
| if (billingDenial) { | |
| return { response: billingDenial, entitlements: ent }; | |
| } | |
| // User lacks required tier -- return 403 | |
| return { | |
| response: new Response( | |
| JSON.stringify({ | |
| error: 'Upgrade required', | |
| requiredTier, | |
| currentTier: ent.features.tier, | |
| planKey: ent.planKey, | |
| }), | |
| { | |
| status: 403, | |
| headers: { 'Content-Type': 'application/json', ...corsHeaders }, | |
| }, | |
| ), | |
| entitlements: ent, | |
| }; | |
| } | |