Skip to content

fix(gateway): accept Dodo entitlement as pro, not just Clerk role — unblocks paying users#3249

Merged
koala73 merged 3 commits into
mainfrom
fix/gateway-accept-dodo-entitlement
Apr 21, 2026
Merged

fix(gateway): accept Dodo entitlement as pro, not just Clerk role — unblocks paying users#3249
koala73 merged 3 commits into
mainfrom
fix/gateway-accept-dodo-entitlement

Conversation

@koala73

@koala73 koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner

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`:

"The Convex entitlement check is the authoritative signal for paying customers — Clerk `publicMetadata.plan` is NOT written by our webhook pipeline"

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

  • let allowed = session.valid && session.role === 'pro';
  • 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();
  • }
  • if (!allowed) {
    return new Response(JSON.stringify({ error: 'Pro subscription required' }), { status: 403 });
  • }
  • }
    ```
  1. Fast path: Clerk role === 'pro' → allow with no Redis/Convex I/O.
  2. Fallback: look up Convex entitlement; allow if `tier >= 1 AND validUntil >= Date.now()` (the `validUntil` gate naturally denies lapsed subscriptions).
  3. Else → 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

  • Frontend analog fix: `src/services/panel-gating.ts` docstring lines 11-27 (already merged)
  • Matches `isCallerPremium` logic at `server/_shared/premium-check.ts:39-50`
  • Doesn't affect the `checkEntitlement` path (lines 404-409) which is only used for tier-gated endpoints in `ENDPOINT_ENTITLEMENTS` — it already reads Convex directly.

Test plan

  • Merge + Vercel deploy.
  • As pro user whose Clerk role is 'free' but Convex entitlement tier=1: load /, confirm Regional Intelligence / Tariffs / Comtrade / National Debt panels populate (currently show empty after 403).
  • As pro user whose Clerk role is 'pro' (if any such users exist): confirm no regression — fast path still allows before touching Redis/Convex.
  • As free signed-in user (no Dodo sub): confirm 403 still fires on premium paths (both signals false).
  • As user whose Dodo subscription lapsed (`validUntil < Date.now()`): confirm 403 fires (allowed to decay to free).
  • As anonymous visitor: confirm 401 still fires (no Bearer at all).

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.
@vercel

vercel Bot commented Apr 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 21, 2026 6:53am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a Convex/Dodo entitlement fallback to the gateway's Bearer gate so that paying subscribers whose Clerk role is still 'free' can access PREMIUM_RPC_PATHS. The core logic mirrors isCallerPremium in server/_shared/premium-check.ts and correctly promotes getEntitlements to a top-level import for the hot path.

  • P1 — incomplete fix: A second Bearer gate block at lines 355–386 (keyCheck.required && !keyCheck.valid) still uses the old session.role !== 'pro' check without the Dodo fallback. Dodo subscribers who also send an invalid X-WorldMonitor-Key header will be denied at that earlier block and never reach the new logic.
  • The dynamic import of getEntitlements at line 345 is now redundant after the top-level import was added and should be removed.

Confidence Score: 3/5

Fix 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

Filename Overview
server/gateway.ts Adds Dodo/Convex entitlement fallback to the main Bearer gate for PREMIUM_RPC_PATHS, but a parallel Bearer gate block (keyCheck.required && !keyCheck.valid, lines 355–386) retains the old Clerk-only check and will still 403 Dodo subscribers who also send an invalid API key header.

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]
Loading

Comments Outside Diff (2)

  1. server/gateway.ts, line 355-372 (link)

    P1 Stale Clerk-only gate still blocks Dodo subscribers

    The fix correctly adds the Dodo fallback in the second Bearer check block (lines 400–418), but this earlier block — reached when keyCheck.required && !keyCheck.valid (i.e. the request carried an API key that failed validation) — still uses the old session.role !== 'pro' guard. A Dodo subscriber who also sends an invalid X-WorldMonitor-Key header will hit this block first, be denied at line 367–372, and never reach the new logic. The two code paths need the same fix.

            const session = await validateBearerToken(authHeader.slice(7));
            if (!session.valid) {
              return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
                status: 401,
                headers: { 'Content-Type': 'application/json', ...corsHeaders },
              });
            }
            let allowed = session.role === 'pro';
            if (!allowed && session.userId) {
              const ent = await getEntitlements(session.userId);
              allowed = !!ent && ent.features.tier >= 1 && ent.validUntil >= Date.now();
            }
            if (!allowed) {
              return new Response(JSON.stringify({ error: 'Pro subscription required' }), {
                status: 403,
                headers: { 'Content-Type': 'application/json', ...corsHeaders },
              });
            }
  2. server/gateway.ts, line 345 (link)

    P2 Redundant dynamic import now shadows top-level import

    getEntitlements was promoted to the top-level static import at line 19 as part of this PR ("promoted to top-level since the new site is in a hotter path"), but this dynamic import still exists. The local binding created here shadows the module-level one for the remainder of this block, making the top-level import unused on this path. The dynamic import line can simply be removed.

Reviews (1): Last reviewed commit: "fix(gateway): accept Dodo entitlement as..." | Re-trigger Greptile

Comment thread server/gateway.ts Outdated
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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
@koala73

koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

You're right — the previous iteration put the entitlement fallback in dead code. Fixed in `6cad8f7b5`.

What was wrong

Tracing the flow for the failing case (Bearer user, path in PREMIUM_RPC_PATHS, NOT in ENDPOINT_ENTITLEMENTS):

```
line 286: isTierGated = false // not in ENDPOINT_ENTITLEMENTS
line 287: needsLegacyProBearerGate = true
line 292: if (isTierGated) → false → sessionUserId stays null
line 309: validateApiKey({ forceKey: true })
line 320: not a wm_ key, skip
line 344: isUserApiKey=false, skip
line 355: keyCheck.required && !keyCheck.valid = TRUE
line 356: needsLegacyProBearerGate = TRUE
line 358: Bearer present → validate
line 367: session.role !== 'pro' → 403 ❌ ← actual block
line 400: unreachable (sessionUserId is null)
```

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 does

Move 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

  • if (session.role !== 'pro') {
  • let allowed = session.role === 'pro';
  • if (!allowed && session.userId) {
  • const ent = await getEntitlements(session.userId);
  • allowed = !!ent && ent.features.tier >= 1 && ent.validUntil >= Date.now();
  • }
  • if (!allowed) {
    return new Response(JSON.stringify({ error: 'Pro subscription required' }), {
    status: 403,
    ...
    });
    }
    ```

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

```
line 367: session.role !== 'pro' (still 'free' for Dodo subscribers)
line 369: fallback: getEntitlements(session.userId)
line 370: tier=1, validUntil=future → allowed = true
line 376: fall through to handler ✓
```

Same fail-closed semantics on negative cases

Case Before After
Anonymous (no Bearer) 401 401
Bearer with invalid JWT 401 401
Free user, no Dodo entitlement 403 403
Pro user, lapsed sub (validUntil < now) 403 403
Pro user, active Dodo, Clerk role 'free' 403 200
Pro user, Clerk role 'pro' 200 200 (fast path, no Convex I/O)

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.
@koala73

koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

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.

@koala73
koala73 merged commit 6977e9d into main Apr 21, 2026
10 checks passed
@koala73
koala73 deleted the fix/gateway-accept-dodo-entitlement branch April 21, 2026 06:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant