fix(gateway): accept Dodo entitlement as pro, not just Clerk role — unblocks paying users#3249
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds a Convex/Dodo entitlement fallback to the gateway's Bearer gate so that paying subscribers whose Clerk role is still
Confidence Score: 3/5Fix is partially correct but a parallel Bearer gate block still uses the Clerk-only check and will deny Dodo subscribers in a specific request pattern. The primary web-UI path is fixed, but the stale check at lines 355–386 is a real defect that leaves the same class of users (Dodo subscribers with Clerk role 'free') blocked when their request also carries an invalid API key header. Until that block is also updated, the fix is incomplete. server/gateway.ts lines 355–386 — the keyCheck.required && !keyCheck.valid Bearer gate block needs the same Dodo entitlement fallback. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming request to PREMIUM_RPC_PATH] --> B{isUserApiKey?}
B -- yes --> C[Check getEntitlements for apiAccess]
C -- no apiAccess --> D[403 API access required]
C -- ok --> E
B -- no --> F{keyCheck.required AND NOT keyCheck.valid?}
F -- yes --> G{Bearer token present?}
G -- no --> H[401]
G -- yes --> I[validateBearerToken]
I -- invalid --> J[401]
I -- valid, role==='pro' --> E
I -- valid, role!='pro' Dodo subscriber --> K["❌ 403 — OLD CHECK\nNo Dodo fallback\n(lines 355–386)"]
F -- no --> L{sessionUserId AND NOT keyCheck.valid?}
L -- no --> E
L -- yes --> M{Bearer token present?}
M -- no --> E
M -- yes --> N[validateBearerToken]
N -- invalid --> E
N -- valid, role==='pro' --> E
N -- valid, role!='pro' --> O[getEntitlements — Convex/Redis]
O -- tier>=1 AND validUntil>=now --> E
O -- otherwise --> P["✅ 403 — NEW CHECK\nDodo fallback added\n(lines 400–418)"]
E[Allow — continue to handler]
|
| if (!allowed && session.valid && session.userId) { | ||
| // Fall through to Convex entitlement as the authoritative signal. | ||
| const ent = await getEntitlements(session.userId); | ||
| allowed = !!ent && ent.features.tier >= 1 && ent.validUntil >= Date.now(); |
There was a problem hiding this comment.
validUntil check creates subtle divergence from isCallerPremium
The PR description says the gateway and isCallerPremium "now agree on who is premium", but they don't quite: isCallerPremium (line 48 of premium-check.ts) checks only ent.features.tier >= 1 without a validUntil guard, so a user whose Convex subscription record has an expired validUntil (e.g. returned fresh from the Convex fallback after Redis eviction) would pass isCallerPremium but be blocked here. The extra guard here is the correct behavior — the pre-existing gap lives in isCallerPremium — but it means gating isn't uniform across code paths and could cause confusing support tickets ("endpoint X works but endpoint Y doesn't") until both are aligned.
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
|
You're right — the previous iteration put the entitlement fallback in dead code. Fixed in `6cad8f7b5`. What was wrongTracing the flow for the failing case (Bearer user, path in PREMIUM_RPC_PATHS, NOT in ENDPOINT_ENTITLEMENTS): ``` The previously-added line-400 fallback never ran because `sessionUserId` is intentionally null on legacy premium routes (JWKS lookup is gated by `isTierGated` to avoid the cost on every request). What the new commit doesMove the entitlement fallback INTO the line-367 site, where the Bearer is already being validated and `session.userId` is exposed on the result (no extra JWKS round-trip — `validateBearerToken` already verified the JWT signature). ```diff
Removed the now-redundant block at the old line 400. Net diff: +23/-34 lines (cleaner). Trace of the same failing case after the fix``` Same fail-closed semantics on negative cases
I should've actually traced the flow on the first iteration instead of pattern-matching the new branch onto a similar-looking guard. Apologies — this is the second time on this PR I've put a fix in the wrong code path. |
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.
|
Cleanup pushed in `a83159f9d` — dropped the now-redundant dynamic `getEntitlements` import at line 345; both call sites now use the top-level import added in the previous commit. Net diff for the PR is now smaller. |
Problem — paying Dodo subscribers get 403 on every premium endpoint
Live report 2026-04-21 after a successful purchase:
```
GET /api/intelligence/v1/get-regional-snapshot?region_id=mena 403 (Forbidden)
GET /api/trade/v1/get-tariff-trends?reporting_country=840&... 403 (Forbidden)
GET /api/trade/v1/list-comtrade-flows 403 (Forbidden)
GET /api/economic/v1/get-national-debt 403 (Forbidden)
POST /api/intelligence/v1/deduct-situation 403 (Forbidden)
```
Root cause: gateway-level split-brain between Clerk and Convex entitlement.
`server/gateway.ts:388-401` (the legacy `PREMIUM_RPC_PATHS` Bearer gate):
```ts
if (!session.valid || session.role !== 'pro') {
return new Response(JSON.stringify({ error: 'Pro subscription required' }), { status: 403 });
}
```
That `session.role` comes from Clerk. But the Dodo webhook pipeline writes Convex entitlements, NOT Clerk `publicMetadata.role` — a gap already documented for the frontend at `src/services/panel-gating.ts:11-27`:
Frontend was fixed (`hasPremiumAccess()` falls through to `isEntitled()`). The backend gateway still only consulted Clerk role, so every paying Dodo subscriber whose Clerk role is `'free'` got 403'd on legacy-premium endpoints — which is essentially every new Pro purchase.
Fix
Align the gateway's Bearer gate with the two-signal logic already present in `server/_shared/premium-check.ts::isCallerPremium`:
```diff
return new Response(JSON.stringify({ error: 'Pro subscription required' }), { status: 403 });
```
`getEntitlements` was already being dynamically imported at line 345 in a different branch; promoted to top-level since the new site is in a hotter path.
Impact
Unblocks all Dodo subscribers whose Clerk role is still `'free'` (i.e. everyone, since webhook-based Clerk role sync was never wired up). Gateway and per-handler `isCallerPremium` now agree on who is premium, closing a split that's been silently causing support tickets since the Dodo migration.
Related
Test plan