mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user