diff --git a/server/gateway.ts b/server/gateway.ts index 49a32f083..2e80d9540 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -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.