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.
This commit is contained in:
Elie Habib
2026-04-21 10:55:09 +04:00
committed by GitHub
parent 4d9ae3b214
commit 6977e9d0fe

View File

@@ -16,7 +16,7 @@ import { validateApiKey } from '../api/_api-key.js';
import { mapErrorToResponse } from './error-mapper';
import { checkRateLimit, checkEndpointRateLimit, hasEndpointRatePolicy } from './_shared/rate-limit';
import { drainResponseHeaders } from './_shared/response-headers';
import { checkEntitlement, getRequiredTier } from './_shared/entitlement-check';
import { checkEntitlement, getRequiredTier, getEntitlements } from './_shared/entitlement-check';
import { resolveSessionUserId } from './_shared/auth-session';
import type { ServerOptions } from '../src/generated/server/worldmonitor/seismology/v1/service_server';
@@ -342,7 +342,6 @@ export function createDomainGateway(
// User API keys on PREMIUM_RPC_PATHS need verified pro-tier entitlement.
// Admin keys (WORLDMONITOR_VALID_KEYS) bypass this since they are operator-issued.
if (isUserApiKey && needsLegacyProBearerGate && sessionUserId) {
const { getEntitlements } = await import('./_shared/entitlement-check');
const ent = await getEntitlements(sessionUserId);
if (!ent || !ent.features.apiAccess) {
return new Response(JSON.stringify({ error: 'API access subscription required' }), {
@@ -364,13 +363,34 @@ export function createDomainGateway(
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (session.role !== 'pro') {
// Accept EITHER a Clerk 'pro' role OR a Convex Dodo entitlement with
// tier >= 1. The Dodo webhook pipeline writes Convex entitlements but
// does NOT sync Clerk publicMetadata.role, so a paying subscriber's
// session.role stays 'free' indefinitely. A Clerk-role-only check
// would block every paying user on legacy premium endpoints despite
// a valid Dodo subscription. This mirrors the two-signal logic in
// server/_shared/premium-check.ts::isCallerPremium so the gateway
// gate and the per-handler gate agree on who is premium — same split
// already documented at the frontend layer (panel-gating.ts:11-27).
//
// Note: validateBearerToken returns session.userId directly, so we
// use it without needing to resolveSessionUserId() — sessionUserId
// is intentionally only resolved for ENDPOINT_ENTITLEMENTS-tier-gated
// endpoints earlier (line 292) to avoid a JWKS lookup on every
// legacy premium request. validateBearerToken already does its own
// verification here (line 360) and exposes userId on the result.
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 },
});
}
// Valid pro session — fall through to route handling
// Valid pro session (Clerk role OR Dodo entitlement) — fall through to route handling.
} else {
return new Response(JSON.stringify({ error: keyCheck.error, _debug: (keyCheck as any)._debug }), {
status: 401,
@@ -385,22 +405,6 @@ export function createDomainGateway(
}
}
// Bearer role check — authenticated users who bypassed the API key gate still
// need a pro role for PREMIUM_RPC_PATHS (entitlement check below handles tier-gated).
if (sessionUserId && !keyCheck.valid && needsLegacyProBearerGate) {
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const { validateBearerToken } = await import('./auth-session');
const session = await validateBearerToken(authHeader.slice(7));
if (!session.valid || session.role !== 'pro') {
return new Response(JSON.stringify({ error: 'Pro subscription required' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
}
// Entitlement check — blocks tier-gated endpoints for users below required tier.
// Admin API-key holders (WORLDMONITOR_VALID_KEYS) bypass entitlement checks.
// User API keys do NOT bypass — the key owner's tier is checked normally.