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,
  };
}