File size: 39,667 Bytes
20f83d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 | /**
* 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,
};
}
|