Files
worldmonitor/server
Elie Habib 6977e9d0fe fix(gateway): accept Dodo entitlement as pro, not just Clerk role — unblocks paying users (#3249)
* fix(gateway): accept Dodo entitlement as pro, not just Clerk role

The gateway's legacy premium-paths gate (lines 388-401) was rejecting
authenticated Bearer users with 403 "Pro subscription required"
whenever session.role !== 'pro' — which is EVERY paying Dodo
subscriber, because the Dodo webhook pipeline writes Convex
entitlements and does NOT sync Clerk publicMetadata.role.

So the flow was:
  - User pays, Dodo webhook fires, Convex entitlement tier=1 written
  - User loads the dashboard, Clerk token includes Bearer but role='free'
  - Gateway sees role!=='pro' → 403 on every intelligence/trade/
    economic/sanctions premium endpoint
  - User sees a blank dashboard despite having paid

This is the exact split-brain documented at the frontend layer
(src/services/panel-gating.ts:11-27): "The Convex entitlement check
is the authoritative signal for paying customers — Clerk
`publicMetadata.plan` is NOT written by our webhook pipeline". The
frontend was fixed by having hasPremiumAccess() fall through to
isEntitled() from Convex. The backend gateway still had the
Clerk-role-only gate, so paying users got rejected even though
their Convex entitlement was active.

Align the gateway gate with the logic already in
server/_shared/premium-check.ts::isCallerPremium (line 44-49):

  1. If Clerk role === 'pro' → allow (fast path, no Redis/Convex I/O)
  2. Else if session.userId → look up Convex entitlement; allow if
     tier >= 1 AND validUntil >= Date.now() (covers lapsed subs)
  3. Else → 403

Same two-signal semantics as the per-handler isCallerPremium, so
the gateway and handlers can't disagree on who is premium. Uses
the already-imported getEntitlements function (line 345 already
imports it dynamically; promoting to top-level import since the new
site is in a hotter path).

Impact: unblocks all Dodo subscribers whose Clerk role is still
'free' — the common case after any fresh Pro purchase and for
every user since webhook-based role sync was never wired up.

Reported 2026-04-21 post-purchase flow: user completed Dodo payment,
landed back on dashboard, saw 403s on get-regional-snapshot,
get-tariff-trends, list-comtrade-flows, get-national-debt,
deduct-situation — all 5 are in PREMIUM_RPC_PATHS but not in
ENDPOINT_ENTITLEMENTS, so they hit this legacy gate.

* fix(gateway): move entitlement fallback to the gate that actually fires

Reviewer caught that the previous iteration of this fix put the
entitlement fallback at line ~400, inside an `if (sessionUserId &&
!keyCheck.valid && needsLegacyProBearerGate)` branch that's
unreachable for the case the PR was supposed to fix:

  - sessionUserId is only resolved when isTierGated is true (line 292)
    — JWKS lookup is intentionally skipped for non-tier-gated paths.
  - needsLegacyProBearerGate IS the non-tier-gated set
    (PREMIUM_RPC_PATHS && !isTierGated).
  - So sessionUserId is null, the branch never enters, and the actual
    legacy-Bearer rejection still happens earlier at line 367 inside
    the `keyCheck.required && !keyCheck.valid` branch.

Move the entitlement fallback INTO the line-367 check, where the
Bearer is already being validated and `session.userId` is already
exposed on the validateBearerToken() result. No extra JWKS round-trip
needed (validateBearerToken already verified the JWT). The previously-
added line-400 block is removed since it never ran.

Now for a paying Dodo subscriber whose Clerk role is still 'free':
  - Bearer validates → role !== 'pro'
  - Fall through: getEntitlements(session.userId) → tier=1, validUntil future
  - allowed = true, request proceeds to handler

Same fail-closed semantics as before for the negative cases:
  - Anonymous → no Bearer → 401
  - Bearer with invalid JWT → 401
  - Free user with no Dodo entitlement → 403
  - Pro user whose Dodo subscription lapsed (validUntil < now) → 403

* chore(gateway): drop redundant dynamic getEntitlements import

Greptile spotted that the previous commit promoted getEntitlements to
a top-level import for the new line-385 fallback site, but the older
dynamic import at line 345 (in the user-API-key entitlement check
branch) was left in place. Same module, same symbol, so the dynamic
import is now dead weight that just adds a microtask boundary to the
hot path.

Drop it; line 345's `getEntitlements(sessionUserId)` call now resolves
through the top-level import like the line-385 site already does.
2026-04-21 10:55:09 +04:00
..