GenerAI / worldmonitor /server /gateway.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
101 kB
/**
* Shared gateway logic for per-domain Vercel edge functions.
*
* Each domain edge function calls `createDomainGateway(routes)` to get a
* request handler that applies CORS, API-key validation, rate limiting,
* POST-to-GET compat, error boundary, and cache-tier headers.
*
* Splitting domains into separate edge functions means Vercel bundles only the
* code for one domain per function, cutting cold-start cost by ~20×.
*/
import { createRouter, type RouteDescriptor } from './router';
import { getCorsHeaders, isDisallowedOrigin, isAllowedOrigin } from './cors';
import { isPublicSharedRpcRequest } from '../src/shared/public-rpc-cache';
import { PRO_FRESH_CACHE_RPC_PATHS } from '../src/shared/pro-fresh-rpc';
// @ts-expect-error — JS module, no declaration file
import { USER_API_KEY_GATEWAY_VALIDATION_ERROR, validateApiKey } from '../api/_api-key.js';
// @ts-expect-error — JS module, no declaration file
import { timingSafeEqualSecret } from '../api/_crypto.js';
// @ts-expect-error — JS module, no declaration file
import { captureSilentError } from '../api/_sentry-edge.js';
import { mapErrorToResponse } from './error-mapper';
import {
checkRateLimit,
checkEndpointRateLimit,
checkFailClosedScopedIpRateLimit,
hasEndpointRatePolicy,
} from './_shared/rate-limit';
import {
drainResponseHeaders,
drainRetryableResponse,
drainSuccessStatusOverride,
} from './_shared/response-headers';
import { projectJsonResponse } from './_shared/response-projection';
import { getRpcNoStoreReasonFromJson } from './_shared/cache-contract';
import {
checkEntitlementDetailed,
getBillingVerificationDenial,
getRequiredTier,
getEntitlements,
isEntitlementBackendConfigured,
type CachedEntitlements,
} from './_shared/entitlement-check';
import { checkProMcpAccess } from './_shared/pro-mcp-gate';
import { resolveClerkSession } from './_shared/auth-session';
import {
INTERNAL_MCP_SIG_HEADER,
INTERNAL_MCP_USER_ID_HEADER,
INTERNAL_MCP_NONCE_HEADER,
INTERNAL_MCP_VERIFIED_HEADER,
TRUSTED_USER_ID_HEADER,
INTERNAL_MCP_REPLAY_CACHE_TTL_SECONDS,
getInternalMcpVerifiedNonce,
sha256Hex,
verifyInternalMcpRequest,
} from './_shared/mcp-internal-hmac';
import { buildUsageIdentity, hashKeySync, type UsageIdentityInput } from './_shared/usage-identity';
import { runRedisPipeline } from './_shared/redis';
import {
beginIdempotency,
peekIdempotency,
IDEMPOTENCY_HEADER,
IDEMPOTENT_REPLAYED_HEADER,
type IdempotencyOutcome,
} from './_shared/idempotency';
import {
checkBurst,
reserveDailyMeter,
rateLimitHeaders,
ENTERPRISE_API_RATE_LIMIT,
} from './_shared/api-key-rate-limit';
import {
DIRECT_LLM_DAILY_QUOTA_LIMIT,
DIRECT_LLM_GATEWAY_QUOTA_PATHS,
reserveDirectLlmQuota,
} from './_shared/direct-llm-quota';
import {
deliverUsageEvents,
buildRequestEvent,
deriveRequestId,
deriveExecutionRegion,
deriveCountry,
deriveIpCity,
deriveIpRegion,
deriveReqBytes,
deriveSentryTraceId,
deriveOriginKind,
deriveUaHash,
deriveIp,
deriveUserAgent,
deriveReferer,
deriveAcceptLanguage,
deriveHost,
maybeAttachDevHealthHeader,
runWithUsageScope,
type CacheTier as UsageCacheTier,
type RequestReason,
} from './_shared/usage';
import { timingSafeEqual } from './_shared/internal-auth';
import type { ServerOptions } from '../src/generated/server/worldmonitor/seismology/v1/service_server';
export const serverOptions: ServerOptions = { onError: mapErrorToResponse };
/**
* Internal-MCP request body size cap (256 KB). Internal-MCP fetches
* carry small JSON-RPC params; this ceiling prevents the gateway from
* buffering arbitrarily large bodies on the strip / HMAC-verify paths.
*
* Applied at:
* - The trust-marker strip block (any Pro-marked inbound request)
* - The HMAC-verify block (signed internal-MCP requests)
*
* Both Content-Length AND post-buffer byte count are checked because
* Content-Length can be absent / wrong for chunked or streamed bodies.
*
* F8 (U7+U8 review pass).
*/
const MAX_INTERNAL_MCP_BODY = 256 * 1024;
type InternalMcpReplayClaim = 'fresh' | 'replay' | 'unavailable';
function getRateLimitTelemetryReason(
response: Response,
rejectedReason: RequestReason,
): RequestReason {
return response.status === 503 &&
response.headers.get('X-RateLimit-Mode') === 'degraded'
? 'rate_limit_degraded'
: rejectedReason;
}
async function claimInternalMcpReplayNonce(userId: string, nonce: string): Promise<InternalMcpReplayClaim> {
const digest = await sha256Hex(`${userId}:${nonce}`);
const key = `internal-mcp-replay:v1:${digest}`;
const result = await runRedisPipeline([
['SET', key, '1', 'EX', INTERNAL_MCP_REPLAY_CACHE_TTL_SECONDS, 'NX'],
]);
if (result.length === 0) return 'unavailable';
const claim = result[0] as { result?: unknown; error?: unknown } | undefined;
if (claim?.error) return 'unavailable';
return claim?.result === 'OK' ? 'fresh' : 'replay';
}
// --- Edge cache tier definitions ---
// NOTE: This map is shared across all domain bundles (~3KB). Kept centralised for
// single-source-of-truth maintainability; the size is negligible vs handler code.
type CacheTier = 'fast' | 'medium' | 'slow' | 'slow-browser' | 'live-browser' | 'static' | 'daily' | 'no-store' | 'live';
// Three-tier caching: browser (max-age) → CF edge (s-maxage) → Vercel CDN (CDN-Cache-Control).
// CF ignores Vary: Origin so it may pin a single ACAO value, but this is acceptable
// since production traffic is same-origin and preview deployments hit Vercel CDN directly.
//
// 'live' tier (60s) is for endpoints with strict freshness contracts — the
// energy-atlas live-tanker map layer requires position fixes to refresh on
// the order of one minute. Every shorter-than-medium tier is custom; we keep
// the existing tiers untouched so unrelated endpoints aren't impacted.
const TIER_HEADERS: Record<CacheTier, string> = {
fast: 'public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600',
medium: 'public, max-age=120, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
slow: 'public, max-age=300, s-maxage=1800, stale-while-revalidate=300, stale-if-error=3600',
'slow-browser': 'max-age=300, stale-while-revalidate=60, stale-if-error=1800',
'live-browser': 'private, max-age=30, stale-while-revalidate=60, stale-if-error=300',
static: 'public, max-age=600, s-maxage=3600, stale-while-revalidate=600, stale-if-error=14400',
daily: 'public, max-age=3600, s-maxage=14400, stale-while-revalidate=7200, stale-if-error=172800',
'no-store': 'no-store',
live: 'public, max-age=30, s-maxage=60, stale-while-revalidate=60, stale-if-error=300',
};
// Vercel CDN-specific cache TTLs — CDN-Cache-Control overrides Cache-Control for
// Vercel's own edge cache, so Vercel can still cache aggressively (and respects
// Vary: Origin correctly) while CF sees no public s-maxage and passes through.
const TIER_CDN_CACHE: Record<CacheTier, string | null> = {
fast: 'public, s-maxage=600, stale-while-revalidate=300, stale-if-error=1200',
medium: 'public, s-maxage=1200, stale-while-revalidate=600, stale-if-error=1800',
slow: 'public, s-maxage=3600, stale-while-revalidate=900, stale-if-error=7200',
'slow-browser': 'public, s-maxage=900, stale-while-revalidate=60, stale-if-error=1800',
'live-browser': null,
static: 'public, s-maxage=14400, stale-while-revalidate=3600, stale-if-error=28800',
daily: 'public, s-maxage=86400, stale-while-revalidate=14400, stale-if-error=172800',
'no-store': null,
live: 'public, s-maxage=60, stale-while-revalidate=60, stale-if-error=300',
};
const RPC_CACHE_TIER: Record<string, CacheTier> = {
// 'live' tier — bbox-quantized + tanker-aware caching upstream of the
// 60s in-handler cache, absorbing identical-bbox requests at the CDN
// before they hit this Vercel function. Energy Atlas live-tanker layer.
'/api/maritime/v1/get-vessel-snapshot': 'live',
'/api/market/v1/list-market-quotes': 'medium',
'/api/market/v1/list-crypto-quotes': 'medium',
'/api/market/v1/list-crypto-sectors': 'slow',
'/api/market/v1/list-defi-tokens': 'slow',
'/api/market/v1/list-ai-tokens': 'slow',
'/api/market/v1/list-other-tokens': 'slow',
'/api/market/v1/list-commodity-quotes': 'medium',
'/api/market/v1/list-stablecoin-markets': 'medium',
'/api/market/v1/get-sector-summary': 'medium',
'/api/market/v1/get-fear-greed-index': 'slow',
'/api/market/v1/get-market-breadth-history': 'daily',
'/api/market/v1/list-gulf-quotes': 'medium',
'/api/market/v1/analyze-stock': 'slow',
'/api/market/v1/get-stock-analysis-history': 'medium',
'/api/market/v1/backtest-stock': 'slow',
'/api/market/v1/list-stored-stock-backtests': 'medium',
'/api/infrastructure/v1/list-service-statuses': 'slow',
'/api/seismology/v1/list-earthquakes': 'slow',
'/api/infrastructure/v1/list-internet-outages': 'slow',
'/api/infrastructure/v1/list-internet-ddos-attacks': 'slow',
'/api/infrastructure/v1/list-internet-traffic-anomalies': 'slow',
'/api/forecast/v1/get-forecast-scorecard': 'fast',
'/api/unrest/v1/list-unrest-events': 'slow',
'/api/cyber/v1/list-cyber-threats': 'static',
'/api/conflict/v1/list-acled-events': 'slow',
'/api/military/v1/get-theater-posture': 'slow',
'/api/infrastructure/v1/get-temporal-baseline': 'slow',
'/api/aviation/v1/list-airport-delays': 'static',
'/api/aviation/v1/get-airport-ops-summary': 'static',
'/api/aviation/v1/list-airport-flights': 'static',
'/api/aviation/v1/get-carrier-ops': 'slow',
'/api/aviation/v1/get-flight-status': 'fast',
'/api/aviation/v1/track-aircraft': 'no-store',
'/api/aviation/v1/search-flight-prices': 'medium',
'/api/aviation/v1/search-google-flights': 'no-store',
'/api/aviation/v1/search-google-dates': 'medium',
'/api/aviation/v1/list-aviation-news': 'slow',
'/api/market/v1/get-country-stock-index': 'slow',
'/api/natural/v1/list-natural-events': 'slow',
'/api/wildfire/v1/list-fire-detections': 'static',
'/api/maritime/v1/list-navigational-warnings': 'static',
'/api/supply-chain/v1/get-china-corridor-control-towers': 'medium',
'/api/supply-chain/v1/get-shipping-rates': 'daily',
'/api/supply-chain/v1/list-pipelines': 'static',
'/api/supply-chain/v1/get-pipeline-detail': 'static',
'/api/supply-chain/v1/list-storage-facilities': 'static',
'/api/supply-chain/v1/get-storage-facility-detail': 'static',
'/api/supply-chain/v1/list-fuel-shortages': 'medium',
'/api/supply-chain/v1/get-fuel-shortage-detail': 'medium',
'/api/supply-chain/v1/list-energy-disruptions': 'medium',
'/api/economic/v1/get-fred-series': 'static',
'/api/economic/v1/get-bls-series': 'daily',
'/api/economic/v1/get-energy-prices': 'static',
'/api/research/v1/list-arxiv-papers': 'static',
'/api/research/v1/list-trending-repos': 'static',
'/api/giving/v1/get-giving-summary': 'static',
'/api/intelligence/v1/get-country-intel-brief': 'static',
// The canonical Railway projection refreshes every 15 minutes. Keep the
// public composition route's Vercel TTL (10m on fast) inside that cadence so
// the seeder cannot keep re-publishing a two-hour-old medium-tier response.
'/api/intelligence/v1/get-china-decision-signals': 'fast',
'/api/intelligence/v1/get-gdelt-topic-timeline': 'medium',
'/api/climate/v1/list-climate-anomalies': 'daily',
'/api/climate/v1/list-climate-disasters': 'daily',
'/api/climate/v1/get-co2-monitoring': 'daily',
'/api/climate/v1/get-ocean-ice-data': 'daily',
'/api/climate/v1/list-air-quality-data': 'fast',
'/api/climate/v1/list-climate-news': 'slow',
'/api/sanctions/v1/list-sanctions-pressure': 'daily',
'/api/sanctions/v1/lookup-sanction-entity': 'no-store',
'/api/radiation/v1/list-radiation-observations': 'slow',
'/api/thermal/v1/list-thermal-escalations': 'slow',
'/api/research/v1/list-tech-events': 'daily',
'/api/military/v1/get-usni-fleet-report': 'daily',
'/api/military/v1/list-defense-patents': 'daily',
'/api/conflict/v1/list-ucdp-events': 'daily',
'/api/conflict/v1/get-humanitarian-summary': 'daily',
'/api/conflict/v1/list-iran-events': 'slow',
'/api/displacement/v1/get-displacement-summary': 'daily',
'/api/displacement/v1/get-population-exposure': 'daily',
'/api/economic/v1/get-bis-policy-rates': 'daily',
'/api/economic/v1/get-bis-exchange-rates': 'daily',
'/api/economic/v1/get-bis-credit': 'daily',
'/api/trade/v1/get-tariff-trends': 'daily',
'/api/trade/v1/get-trade-flows': 'daily',
'/api/trade/v1/get-trade-barriers': 'daily',
'/api/trade/v1/get-trade-restrictions': 'daily',
'/api/trade/v1/get-customs-revenue': 'daily',
'/api/trade/v1/list-comtrade-flows': 'daily',
'/api/economic/v1/list-world-bank-indicators': 'daily',
'/api/economic/v1/get-energy-capacity': 'daily',
'/api/economic/v1/list-grocery-basket-prices': 'daily',
'/api/economic/v1/list-bigmac-prices': 'daily',
'/api/economic/v1/list-fuel-prices': 'daily',
'/api/economic/v1/get-fao-food-price-index': 'daily',
'/api/economic/v1/get-crude-inventories': 'daily',
'/api/economic/v1/get-nat-gas-storage': 'daily',
'/api/economic/v1/get-eu-yield-curve': 'daily',
'/api/supply-chain/v1/get-critical-minerals': 'daily',
'/api/military/v1/get-aircraft-details': 'static',
'/api/military/v1/get-wingbits-status': 'static',
'/api/military/v1/get-wingbits-live-flight': 'no-store',
'/api/military/v1/list-military-flights': 'slow',
'/api/market/v1/list-etf-flows': 'slow',
'/api/research/v1/list-hackernews-items': 'slow',
'/api/intelligence/v1/get-country-risk': 'slow',
'/api/intelligence/v1/get-risk-scores': 'slow',
'/api/intelligence/v1/get-pizzint-status': 'slow',
'/api/intelligence/v1/classify-event': 'static',
'/api/intelligence/v1/search-gdelt-documents': 'slow',
'/api/infrastructure/v1/get-cable-health': 'slow',
'/api/positive-events/v1/list-positive-geo-events': 'slow',
'/api/military/v1/list-military-bases': 'daily',
'/api/economic/v1/get-macro-signals': 'medium',
'/api/economic/v1/get-national-debt': 'daily',
'/api/prediction/v1/list-prediction-markets': 'medium',
'/api/forecast/v1/get-forecasts': 'medium',
'/api/forecast/v1/get-simulation-package': 'slow',
'/api/forecast/v1/get-simulation-outcome': 'slow',
'/api/supply-chain/v1/get-chokepoint-status': 'medium',
'/api/supply-chain/v1/get-chokepoint-history': 'slow',
'/api/news/v1/list-feed-digest': 'slow',
'/api/intelligence/v1/get-country-facts': 'daily',
'/api/intelligence/v1/list-security-advisories': 'slow',
'/api/intelligence/v1/list-satellites': 'static',
'/api/intelligence/v1/list-gps-interference': 'slow',
'/api/intelligence/v1/list-cross-source-signals': 'medium',
'/api/intelligence/v1/list-oref-alerts': 'fast',
'/api/intelligence/v1/list-telegram-feed': 'fast',
'/api/intelligence/v1/get-company-enrichment': 'slow',
'/api/intelligence/v1/list-company-signals': 'slow',
'/api/intelligence/v1/search-sec-filings': 'medium',
'/api/intelligence/v1/list-material-events': 'medium',
'/api/news/v1/summarize-article-cache': 'slow',
'/api/imagery/v1/search-imagery': 'static',
'/api/infrastructure/v1/list-temporal-anomalies': 'medium',
'/api/infrastructure/v1/get-ip-geo': 'no-store',
'/api/infrastructure/v1/reverse-geocode': 'slow',
'/api/infrastructure/v1/get-bootstrap-data': 'no-store',
'/api/webcam/v1/get-webcam-image': 'no-store',
'/api/webcam/v1/list-webcams': 'no-store',
'/api/consumer-prices/v1/get-consumer-price-overview': 'slow',
'/api/consumer-prices/v1/get-consumer-price-basket-series': 'slow',
'/api/consumer-prices/v1/list-consumer-price-categories': 'slow',
'/api/consumer-prices/v1/list-consumer-price-movers': 'slow',
'/api/consumer-prices/v1/list-retailer-price-spreads': 'slow',
'/api/consumer-prices/v1/get-consumer-price-freshness': 'slow',
'/api/aviation/v1/get-youtube-live-stream-info': 'fast',
'/api/market/v1/list-earnings-calendar': 'slow',
'/api/market/v1/get-cot-positioning': 'slow',
'/api/market/v1/get-gold-intelligence': 'slow',
'/api/market/v1/get-hyperliquid-flow': 'medium',
'/api/market/v1/get-insider-transactions': 'slow',
'/api/economic/v1/get-economic-calendar': 'slow',
'/api/economic/v1/get-china-macro-snapshot': 'slow',
'/api/economic/v1/get-china-activity-nowcast': 'medium',
'/api/intelligence/v1/list-market-implications': 'slow',
'/api/economic/v1/get-ecb-fx-rates': 'slow',
'/api/economic/v1/get-eurostat-country-data': 'slow',
'/api/economic/v1/get-eu-gas-storage': 'slow',
'/api/economic/v1/get-oil-stocks-analysis': 'static',
'/api/economic/v1/get-oil-inventories': 'slow',
'/api/economic/v1/get-energy-crisis-policies': 'static',
'/api/economic/v1/list-global-tenders': 'medium',
'/api/economic/v1/get-eu-fsi': 'slow',
'/api/economic/v1/get-economic-stress': 'slow',
'/api/supply-chain/v1/get-shipping-stress': 'medium',
'/api/supply-chain/v1/get-country-chokepoint-index': 'slow-browser',
'/api/supply-chain/v1/get-bypass-options': 'slow-browser',
'/api/supply-chain/v1/get-country-cost-shock': 'slow-browser',
'/api/supply-chain/v1/get-country-products': 'slow-browser',
'/api/supply-chain/v1/get-multi-sector-cost-shock': 'slow-browser',
'/api/supply-chain/v1/get-sector-dependency': 'slow-browser',
'/api/supply-chain/v1/get-route-explorer-lane': 'slow-browser',
'/api/supply-chain/v1/get-route-impact': 'slow-browser',
// Scenario engine: list-scenario-templates is a compile-time constant catalog;
// daily tier gives browser max-age=3600 matching the legacy /api/scenario/v1/templates
// endpoint header. get-scenario-status is premium-gated — gateway short-circuits
// to 'slow-browser' but the entry is still required by tests/route-cache-tier.test.mjs.
'/api/scenario/v1/list-scenario-templates': 'daily',
'/api/scenario/v1/get-scenario-status': 'slow-browser',
'/api/health/v1/list-disease-outbreaks': 'slow',
'/api/health/v1/list-air-quality-alerts': 'fast',
'/api/intelligence/v1/get-social-velocity': 'fast',
'/api/intelligence/v1/get-country-energy-profile': 'slow',
'/api/intelligence/v1/compute-energy-shock': 'fast',
'/api/intelligence/v1/get-country-port-activity': 'slow',
// NOTE: get-regional-snapshot is premium-gated via PREMIUM_RPC_PATHS; the
// gateway short-circuits to 'slow-browser' before consulting this map. The
// entry below exists to satisfy the parity contract enforced by
// tests/route-cache-tier.test.mjs (every generated GET route needs a tier)
// and documents the intended tier if the endpoint ever becomes non-premium.
'/api/intelligence/v1/get-regional-snapshot': 'slow',
// get-regime-history is premium-gated same as get-regional-snapshot; this
// entry is required by tests/route-cache-tier.test.mjs even though the
// gateway short-circuits premium paths to slow-browser.
'/api/intelligence/v1/get-regime-history': 'slow',
// get-regional-brief is premium-gated; slow-browser in practice, slow entry for route-parity.
'/api/intelligence/v1/get-regional-brief': 'slow',
// Historical intelligence memory (#5694) — the timeline is a generated GET
// and therefore requires an explicit gateway cache tier. The two semantic
// reads are POSTs and cache successful results inside their handlers.
'/api/intelligence/v1/get-intel-timeline': 'slow',
'/api/resilience/v1/get-resilience-score': 'slow',
'/api/resilience/v1/get-resilience-ranking': 'slow',
'/api/resilience/v1/get-runtime-manifest': 'no-store',
// Partner-facing shipping/v2. route-intelligence is premium-gated; gateway
// short-circuits to slow-browser. Entry required by tests/route-cache-tier.test.mjs.
'/api/v2/shipping/route-intelligence': 'slow-browser',
// GET /webhooks lists caller's webhooks — premium-gated; short-circuited to
// slow-browser. Entry required by tests/route-cache-tier.test.mjs.
'/api/v2/shipping/webhooks': 'slow-browser',
};
import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths';
export const PUBLIC_NO_AUTH_RPC_PATHS = new Set<string>([
'/api/conflict/v1/list-acled-events',
'/api/natural/v1/list-natural-events',
'/api/intelligence/v1/get-china-decision-signals',
'/api/resilience/v1/get-runtime-manifest',
'/api/seismology/v1/list-earthquakes',
'/api/unrest/v1/list-unrest-events',
// Lead-capture RPCs serve ANONYMOUS prospects by definition: the /pro
// marketing page contact form and the waitlist/desktop signup both POST
// without a wms_ session or API key (see pro-test/src/App.tsx onSubmit and
// src/services/runtime.ts isKeyFreeApiTarget). A freely-mintable anonymous
// session token would add zero abuse protection here — the real gates live
// in the handlers: server-side Turnstile (fails closed in production),
// honeypot, free-email-domain rejection, per-IP endpoint rate limits
// (server/_shared/rate-limit.ts: 3/h and 5/h), and the Convex per-email
// throttle. Pinned by tests/leads-gateway-public.test.mts.
'/api/leads/v1/submit-contact',
'/api/leads/v1/register-interest',
]);
// Cacheable, non-premium RPC endpoints the Railway relay periodically warm-pings
// to keep their compute caches hot (so the first real user request isn't a cold
// miss). These require a browser session token or an API key in normal traffic;
// the relay is a trusted internal service with neither, so it authenticates as
// itself via WORLDMONITOR_RELAY_KEY (validated below in isRelayWarmPingRequest).
//
// Least privilege: WORLDMONITOR_RELAY_KEY is a DEDICATED relay↔gateway secret —
// it does NOT need to be (and should not be) a WORLDMONITOR_VALID_KEYS enterprise
// key. It unlocks ONLY a cache-warm on these specific free endpoints — exactly
// what any session holder could already trigger — so the blast radius of the
// secret is a recompute on public data: no premium access, no entitlement bypass
// beyond anonymous-equivalent. Mirrors the isResilienceRankingSeedRefreshRequest
// internal-auth path below.
export const RELAY_WARM_PING_PATHS = new Set<string>([
'/api/infrastructure/v1/list-service-statuses',
'/api/infrastructure/v1/get-cable-health',
'/api/infrastructure/v1/list-temporal-anomalies',
'/api/intelligence/v1/get-risk-scores',
'/api/supply-chain/v1/get-chokepoint-status',
]);
/**
* Creates a Vercel Edge handler for a single domain's routes.
*
* Applies the full gateway pipeline: origin check → CORS → OPTIONS preflight →
* API key → rate limit → route match (with POST→GET compat) → execute → cache headers.
*/
export type GatewayCtx = { waitUntil: (p: Promise<unknown>) => void };
const POST_TO_GET_MAX_BODY_BYTES = 1_048_576;
const POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY = 200;
export const REQUIRED_BBOX_QUERY_PARAMS = ['sw_lat', 'sw_lon', 'ne_lat', 'ne_lon'] as const;
// Issue #4595 is scoped to military RPCs whose handlers require bbox.
// Other bbox-capable RPCs support lookup/global modes and must not emit this diagnostic.
export const REQUIRED_BBOX_RPC_PATHS = [
'/api/military/v1/list-military-bases',
'/api/military/v1/list-military-flights',
] as const;
const REQUIRED_BBOX_RPC_PATH_SET = new Set<string>(REQUIRED_BBOX_RPC_PATHS);
const MILITARY_BBOX_DIAGNOSTIC_PATH_SET = new Set<string>(REQUIRED_BBOX_RPC_PATHS);
function isPostToGetCompatibleBodySize(headers: Headers): boolean {
const rawContentLength = headers.get('Content-Length');
if (rawContentLength === null || !/^\d+$/.test(rawContentLength)) return false;
const contentLength = Number(rawContentLength);
return Number.isSafeInteger(contentLength) && contentLength < POST_TO_GET_MAX_BODY_BYTES;
}
function getRequiredBboxQueryProblems(searchParams: URLSearchParams): { missing: string[]; invalid: string[]; allZero: boolean } {
const absent: string[] = [];
const invalid: string[] = [];
const values: number[] = [];
for (const param of REQUIRED_BBOX_QUERY_PARAMS) {
const raw = searchParams.get(param);
if (raw == null) {
absent.push(param);
continue;
}
if (raw.trim() === '') {
invalid.push(param);
continue;
}
const value = Number(raw);
if (!Number.isFinite(value)) {
invalid.push(param);
continue;
}
values.push(value);
}
const missing = absent.length === REQUIRED_BBOX_QUERY_PARAMS.length ? [...REQUIRED_BBOX_QUERY_PARAMS] : [];
return {
missing,
invalid,
allZero: absent.length === 0 && invalid.length === 0 && values.every((value) => value === 0),
};
}
type RequiredBboxDiagnostic = {
status: 'missing' | 'invalid';
missing: string[];
invalid: string[];
};
function getRequiredBboxDiagnostic(request: Request, pathname: string): RequiredBboxDiagnostic | null {
if (!REQUIRED_BBOX_RPC_PATH_SET.has(pathname)) return null;
const { searchParams } = new URL(request.url);
const { missing, invalid, allZero } = getRequiredBboxQueryProblems(searchParams);
if (missing.length === 0 && invalid.length === 0 && !allZero) return null;
return {
status: missing.length > 0 ? 'missing' : 'invalid',
missing,
invalid: allZero ? [...REQUIRED_BBOX_QUERY_PARAMS] : invalid,
};
}
function attachRequiredBboxDiagnosticHeaders(
headers: Headers,
pathname: string,
diagnostic: RequiredBboxDiagnostic | null,
): void {
if (!diagnostic) return;
headers.set('X-WorldMonitor-Bbox', diagnostic.status);
if (diagnostic.missing.length > 0) headers.set('X-WorldMonitor-Bbox-Missing', diagnostic.missing.join(','));
if (diagnostic.invalid.length > 0) headers.set('X-WorldMonitor-Bbox-Invalid', diagnostic.invalid.join(','));
if (MILITARY_BBOX_DIAGNOSTIC_PATH_SET.has(pathname)) {
// Issue #4595 explicitly requested the military alias; keep it as a stable consumer affordance.
headers.set('X-Military-Bbox', diagnostic.status);
}
}
// `TRUSTED_USER_ID_HEADER` (a.k.a. `x-user-id`) is gateway-internal: the
// gateway is the ONLY layer permitted to set it, and it must reflect an
// authenticated principal. Inbound client copies are stripped at handler
// entry (see stripClientUserIdHeader); the authenticated value is re-
// injected after Clerk / wm_ user-key / legacy bearer auth via
// withAuthenticatedUserId. The internal-MCP block below has its own
// strip-and-rebuild step that ALSO strips this header alongside
// INTERNAL_MCP_VERIFIED_HEADER — both layers are defense-in-depth.
function cloneRequestWithHeaders(request: Request, headers: Headers): Request {
return new Request(request, { headers });
}
function stripClientUserIdHeader(request: Request): Request {
if (!request.headers.has(TRUSTED_USER_ID_HEADER)) return request;
const headers = new Headers(request.headers);
headers.delete(TRUSTED_USER_ID_HEADER);
return cloneRequestWithHeaders(request, headers);
}
function withAuthenticatedUserId(request: Request, userId: string): Request {
const headers = new Headers(request.headers);
headers.set(TRUSTED_USER_ID_HEADER, userId);
return cloneRequestWithHeaders(request, headers);
}
function normalizeAuthError(error: string | undefined): string {
if (!error || error === USER_API_KEY_GATEWAY_VALIDATION_ERROR) return 'Invalid API key';
return error;
}
function createGatewayAuthErrorResponse(
status: 401 | 403,
error: string | undefined,
corsHeaders: Record<string, string>,
): Response {
return new Response(JSON.stringify({ error: normalizeAuthError(error) }), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...corsHeaders,
},
});
}
const GATEWAY_DIRECT_LLM_QUOTA_METHODS: Record<string, string> = {
'/api/intelligence/v1/classify-event': 'GET',
'/api/intelligence/v1/deduct-situation': 'POST',
'/api/intelligence/v1/get-country-intel-brief': 'GET',
'/api/market/v1/analyze-stock': 'GET',
'/api/news/v1/summarize-article': 'POST',
};
async function shouldReserveGatewayDirectLlmQuota(request: Request, pathname: string): Promise<boolean> {
if (!DIRECT_LLM_GATEWAY_QUOTA_PATHS.has(pathname)) return false;
if (GATEWAY_DIRECT_LLM_QUOTA_METHODS[pathname] !== request.method) return false;
if (pathname !== '/api/news/v1/summarize-article') return true;
const contentLength = Number(request.headers.get('Content-Length') ?? '0');
if (Number.isFinite(contentLength) && contentLength >= POST_TO_GET_MAX_BODY_BYTES) {
return true;
}
try {
const body = await request.clone().json() as { mode?: unknown };
return body.mode !== 'translate';
} catch {
// Malformed summarize requests cannot reach provider spend; let the handler
// return the established validation error without charging quota.
return false;
}
}
function createDirectLlmQuotaFailureResponse(
reservation: Awaited<ReturnType<typeof reserveDirectLlmQuota>>,
corsHeaders: Record<string, string>,
): Response {
if (reservation.ok) {
throw new Error('createDirectLlmQuotaFailureResponse called for successful reservation');
}
if (reservation.reason === 'cap-exceeded') {
return new Response(JSON.stringify({
error: 'Direct LLM daily quota exceeded',
limit: DIRECT_LLM_DAILY_QUOTA_LIMIT,
resetsAt: 'next UTC midnight',
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': String(reservation.retryAfterSec),
...corsHeaders,
},
});
}
return new Response(JSON.stringify({ error: 'Direct LLM quota unavailable' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': String(reservation.retryAfterSec),
...corsHeaders,
},
});
}
function markAuthErrorNoStore(response: Response): Response {
response.headers.set('Cache-Control', 'no-store');
response.headers.delete('CDN-Cache-Control');
response.headers.delete('Vercel-CDN-Cache-Control');
return response;
}
function hasCredentialBearingHeader(request: Request): boolean {
return Boolean(
request.headers.get('Authorization') ||
request.headers.get('X-WorldMonitor-Key') ||
request.headers.get('X-Api-Key') ||
request.headers.get('Cookie'),
);
}
async function isResilienceRankingSeedRefreshRequest(request: Request, pathname: string): Promise<boolean> {
if (pathname !== '/api/resilience/v1/get-resilience-ranking') return false;
const expected = process.env.WORLDMONITOR_SEED_REFRESH_KEY?.trim() ?? '';
if (!expected) return false;
try {
const url = new URL(request.url);
if (url.searchParams.get('refresh') !== '1') return false;
} catch {
return false;
}
const candidate = request.headers.get('X-WorldMonitor-Key') ?? '';
return timingSafeEqual(candidate, expected);
}
// Authenticate a relay warm-ping as a trusted internal caller. True only when
// the path is an explicit warm-ping target AND the request carries the dedicated
// relay secret in X-WorldMonitor-Key (timing-safe compared). Returns false when
// the secret is unset so a misconfigured deploy fails CLOSED (no bypass) rather
// than silently opening these paths. Mirrors isResilienceRankingSeedRefreshRequest.
export async function isRelayWarmPingRequest(request: Request, pathname: string): Promise<boolean> {
if (!RELAY_WARM_PING_PATHS.has(pathname)) return false;
const expected = process.env.WORLDMONITOR_RELAY_KEY?.trim() ?? '';
if (!expected) return false;
const candidate = request.headers.get('X-WorldMonitor-Key') ?? '';
return timingSafeEqual(candidate, expected);
}
function assertProMcpGatewayHmacConfig(): void {
const proGrantSecret = process.env.MCP_PRO_GRANT_HMAC_SECRET?.trim() ?? '';
const internalSecret = process.env.MCP_INTERNAL_HMAC_SECRET?.trim() ?? '';
if (proGrantSecret && !internalSecret) {
throw new Error('MCP_INTERNAL_HMAC_SECRET must be configured when MCP_PRO_GRANT_HMAC_SECRET is set');
}
}
export function createDomainGateway(
routes: RouteDescriptor[],
): (req: Request, ctx?: GatewayCtx) => Promise<Response> {
assertProMcpGatewayHmacConfig();
const router = createRouter(routes);
return async function handler(originalRequest: Request, ctx?: GatewayCtx): Promise<Response> {
let request = stripClientUserIdHeader(originalRequest);
const rawPathname = new URL(request.url).pathname;
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname;
const t0 = Date.now();
// Usage-telemetry identity inputs — accumulated as gateway auth resolution progresses.
// Read at every return point; null/0 defaults are valid for early returns.
//
// x-widget-key is intentionally NOT trusted here: a header is attacker-
// controllable, and emitting it as `customer_id` would let unauthenticated
// callers poison per-customer dashboards (per koala #3403 review). We only
// populate `widgetKey` after validating it against the configured
// WIDGET_AGENT_KEY — same check used in api/widget-agent.ts.
const rawWidgetKey = request.headers.get('x-widget-key') ?? null;
const widgetAgentKey = process.env.WIDGET_AGENT_KEY ?? '';
const validatedWidgetKey =
await timingSafeEqualSecret(rawWidgetKey, widgetAgentKey) ? rawWidgetKey : null;
const usage: UsageIdentityInput = {
sessionUserId: null,
isUserApiKey: false,
enterpriseApiKey: null,
widgetKey: validatedWidgetKey,
clerkOrgId: null,
userApiKeyCustomerRef: null,
tier: null,
planKey: null,
};
function recordUsageEntitlement(ent: CachedEntitlements | null): void {
if (!ent) return;
// The synthesized verification marker is not an answer about this
// caller's plan — it is free-SHAPED so the gates deny, nothing more.
// Copying its tier-0/'free' fields into usage telemetry would durably
// label unverifiable paying callers as free in Axiom, and it would do so
// precisely during the outage window this data exists to diagnose. Leave
// both fields null, which is what an unanswered lookup used to record
// back when this state arrived as a null (#5619 follow-up).
if (ent.verificationUnavailable) return;
usage.tier = typeof ent.features.tier === 'number' ? ent.features.tier : 0;
usage.planKey = ent.planKey;
}
// Domain segment for telemetry. Path layouts:
// /api/<domain>/v1/<rpc> → parts[2] = domain
// /api/v2/<domain>/<rpc> → parts[2] = "v2", parts[3] = domain
const _parts = pathname.split('/');
const domain = (/^v\d+$/.test(_parts[2] ?? '') ? _parts[3] : _parts[2]) ?? '';
const reqBytes = deriveReqBytes(request);
// #3199: in shadow mode a per-account limit that WOULD have triggered is
// recorded on the single terminal success emit (never a second event) so the
// volume signal Phase-2 pricing reuses isn't double-counted. Overrides only
// a successful terminal reason (status < 400); a real 4xx/5xx outcome wins.
let pendingShadowReason: RequestReason | null = null;
// Shared emit+return for the three billing-verification denial sites below
// (internal-MCP re-check, wm_ key, legacy bearer).
function denyForBillingVerification(
ent: CachedEntitlements | null | undefined,
cors: Record<string, string>,
capabilityCovered = false,
): Response | null {
if (capabilityCovered) return null;
const billingDenial = getBillingVerificationDenial(ent, cors);
if (!billingDenial) return null;
emitRequest(
billingDenial.status,
billingDenial.status === 503 ? 'billing_verification_503' : 'tier_403',
null,
);
return billingDenial;
}
function emitRequest(status: number, reason: RequestReason, cacheTier: UsageCacheTier | null, resBytes = 0): void {
if (!ctx?.waitUntil) return;
const effectiveReason: RequestReason =
pendingShadowReason && status < 400 ? pendingShadowReason : reason;
const identity = buildUsageIdentity(usage);
// Single ctx.waitUntil() registered synchronously in the request phase.
// The IIFE awaits ua_hash (SHA-256) then awaits delivery directly via
// deliverUsageEvents — no nested waitUntil call, which Edge runtimes
// (Cloudflare/Vercel) may drop after the response phase ends.
ctx.waitUntil((async () => {
const uaHash = await deriveUaHash(originalRequest);
await deliverUsageEvents([
buildRequestEvent({
requestId: deriveRequestId(originalRequest),
domain,
route: pathname,
method: originalRequest.method,
status,
durationMs: Date.now() - t0,
reqBytes,
resBytes,
customerId: identity.customer_id,
principalId: identity.principal_id,
authKind: identity.auth_kind,
tier: identity.tier,
planKey: identity.plan_key,
country: deriveCountry(originalRequest),
ipCity: deriveIpCity(originalRequest),
ipRegion: deriveIpRegion(originalRequest),
executionRegion: deriveExecutionRegion(originalRequest),
executionPlane: 'vercel-edge',
originKind: deriveOriginKind(originalRequest),
cacheTier,
ip: deriveIp(originalRequest),
userAgent: deriveUserAgent(originalRequest),
uaHash,
referer: deriveReferer(originalRequest),
acceptLanguage: deriveAcceptLanguage(originalRequest),
host: deriveHost(originalRequest),
sentryTraceId: deriveSentryTraceId(originalRequest),
reason: effectiveReason,
}),
]);
})());
}
// Origin check — skip CORS headers for disallowed origins
if (isDisallowedOrigin(request)) {
emitRequest(403, 'origin_403', null);
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Fail closed on CORS-header generation errors. Previous behaviour fell
// back to a wildcard ACAO, which converted the allowlist into wildcard
// CORS on the error path. Now we omit CORS headers and surface a 500
// so the browser blocks any cross-origin read. See issue #3705.
let corsHeaders: Record<string, string>;
try {
corsHeaders = getCorsHeaders(request);
} catch (err) {
// Pass the Sentry delivery promise through ctx.waitUntil so the
// Vercel Edge isolate survives long enough to actually flush the
// event. (captureSilentError uses keepalive:true as a transport
// fallback when ctx is absent, but the explicit waitUntil is the
// documented best practice.)
const captured = captureSilentError(err, {
tags: { route: 'gateway', step: 'cors_headers' },
});
ctx?.waitUntil(captured);
emitRequest(500, 'cors_error', null);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
// Prevent CDN/edge from caching the 500 — a transient CORS
// failure must not be pinned for downstream callers.
'Cache-Control': 'no-store',
},
});
}
// OPTIONS preflight
if (request.method === 'OPTIONS') {
emitRequest(204, 'preflight', null);
return new Response(null, { status: 204, headers: corsHeaders });
}
// ----------------------------------------------------------------------
// Defense-in-depth: strip client-controlled copies of the trusted
// internal-MCP markers BEFORE any other logic runs. The gateway is the
// ONLY layer permitted to set `x-wm-mcp-internal-verified` /
// `x-user-id` (the latter is also set by verified session / user-key
// paths below). Without the strip step, an attacker
// who sends `x-wm-mcp-internal-verified: 1` from outside could spoof
// premium context to any handler that reads these markers via
// `isCallerPremium`. The strip MUST run regardless of whether the
// X-WM-MCP-Internal header is present, so that the legacy
// `validateApiKey` path also receives a sanitised request.
//
// Mutation invariant: every subsequent request reconstruction in this
// function must build from the (already-stripped) `request`, not from
// `originalRequest`.
// ----------------------------------------------------------------------
{
const inboundHeaders = request.headers;
if (
inboundHeaders.has(INTERNAL_MCP_VERIFIED_HEADER) ||
inboundHeaders.has(TRUSTED_USER_ID_HEADER)
) {
const stripped = new Headers(inboundHeaders);
stripped.delete(INTERNAL_MCP_VERIFIED_HEADER);
stripped.delete(TRUSTED_USER_ID_HEADER);
// For GET/HEAD: no body to forward. For other methods: buffer the
// body bytes and pass them to the new Request — `body: request.body`
// (a ReadableStream) requires `duplex: 'half'` in Node's undici
// Request constructor, and the cleaner cross-runtime approach is
// to forward bytes. Internal-MCP payloads are small JSON RPC params.
//
// F8: cap the buffered body at MAX_INTERNAL_MCP_BODY (256 KB).
// Internal-MCP and gateway-bypass-strip paths only carry small
// JSON-RPC params; 256 KB is a safe ceiling that prevents an
// attacker from forcing the gateway to allocate megabytes of
// memory just by setting Content-Length on a forged request.
const reInit: RequestInit = { method: request.method, headers: stripped };
if (request.method !== 'GET' && request.method !== 'HEAD') {
const contentLen = parseInt(request.headers.get('Content-Length') ?? '0', 10);
if (Number.isFinite(contentLen) && contentLen > MAX_INTERNAL_MCP_BODY) {
// F14: distinct reason label for body-size rejections so
// telemetry separates this class from auth-401s.
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
try {
const bytes = await request.clone().arrayBuffer();
// Defense-in-depth: also reject if the actual buffered byte
// count exceeds the cap (Content-Length can be absent or
// wrong on chunked / streamed bodies).
if (bytes.byteLength > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
reInit.body = bytes;
} catch {
// If we can't buffer the body, we can't safely forward the
// request without trust-markers stripped. 400 the caller.
// F14: use a distinct telemetry reason — "auth_401" was
// misleading (this is a body-buffer failure, not an auth
// outcome).
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'malformed_request' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
request = new Request(request.url, reInit);
}
}
// ----------------------------------------------------------------------
// Internal-MCP HMAC pre-check — runs BEFORE `validateApiKey` so that a
// verified Pro tool fetch never needs an `X-WorldMonitor-Key`. If
// `X-WM-MCP-Internal` is present, treat as a deliberate signed request:
// - verify ⇒ entitlement re-check ⇒ rebuild Request with trusted markers
// - verify FAILS ⇒ 401 immediately (do NOT fall through; present-but-
// invalid is a forge attempt, falling through to validateApiKey
// would let an attacker chain the legacy auth path).
// If the header is absent, fall through to the existing validateApiKey
// path with the (header-stripped) request — Starter+ wm_ keys remain
// unchanged.
//
// When this flag is true, downstream auth gates (validateApiKey, the
// PREMIUM_RPC_PATHS bearer gate, IP rate limiting) are skipped. The MCP
// edge already enforced 50/day + 60/min/userId; the gateway-level
// entitlement check for ENDPOINT_ENTITLEMENTS is also skipped here
// because we re-checked tier ≥ 1 + mcpAccess === true above.
// ----------------------------------------------------------------------
let internalMcpVerified = false;
if (request.headers.has(INTERNAL_MCP_SIG_HEADER)) {
const hmacSecret = process.env.MCP_INTERNAL_HMAC_SECRET ?? '';
if (!hmacSecret) {
// Server misconfiguration on the HMAC-attempt path. Surface as 500
// CONFIGURATION so operators see it; legacy wm_ key path is
// unaffected because we only enter this branch when the caller
// explicitly tried to use the internal-MCP route.
emitRequest(500, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'CONFIGURATION', detail: 'MCP_INTERNAL_HMAC_SECRET not configured' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Read the body bytes ONCE upfront. We need them in three places:
// 1. Inside verifyInternalMcpRequest for the bodyHash compare
// 2. To rebuild a fresh Request with trusted markers (Node's undici
// Request constructor refuses a ReadableStream body without
// `duplex: 'half'`; passing bytes sidesteps that)
// 3. To make the body re-readable by the downstream handler — once
// a stream is locked, subsequent reads throw.
// Reading then passing buffered bytes is safe for internal-MCP
// payloads (small JSON RPC params); not appropriate for streamed
// uploads, which this path doesn't carry.
let bodyBytes: ArrayBuffer | null = null;
if (request.method !== 'GET' && request.method !== 'HEAD') {
// F8: cap inbound body BEFORE buffering. Internal-MCP signed
// requests carry small JSON-RPC params; 256 KB is a safe ceiling.
const contentLen = parseInt(request.headers.get('Content-Length') ?? '0', 10);
if (Number.isFinite(contentLen) && contentLen > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
try {
bodyBytes = await request.clone().arrayBuffer();
} catch {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
if (bodyBytes.byteLength > MAX_INTERNAL_MCP_BODY) {
emitRequest(413, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'payload_too_large' }), {
status: 413,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
// Reconstruct request from buffered bytes so verify can clone freely
// and the downstream handler can read the body normally.
request = new Request(request.url, {
method: request.method,
headers: request.headers,
body: bodyBytes,
});
}
// verifyInternalMcpRequest returns null when X-WM-MCP-User-Id is
// missing, signature header is malformed, timestamp is out of
// window, or the HMAC compare fails. All collapse to a single 401 —
// intentionally do NOT distinguish (don't leak which piece failed
// to a forge probe).
const verified = await verifyInternalMcpRequest(request, hmacSecret);
if (!verified) {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
const replayClaim = await claimInternalMcpReplayNonce(verified.userId, verified.nonce);
if (replayClaim === 'unavailable') {
// Fail closed: without an atomic replay-cache claim, a valid captured
// signature could be reused throughout the timestamp window.
emitRequest(503, 'replay_cache_unavailable', null);
return new Response(
JSON.stringify({ error: 'internal_mcp_replay_cache_unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
if (replayClaim === 'replay') {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'invalid_internal_mcp_signature' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Entitlement re-check at the gateway: the MCP edge already verifies
// tier ≥ 1 + mcpAccess + validUntil before signing the outbound
// fetch (api/mcp.ts). This second check defends against (a) the
// edge being bypassed (e.g. captured signature + leaked secret), (b)
// mid-request entitlement lapse, (c) future regressions where a
// non-edge caller signs requests.
//
// F1 (U7+U8 review pass): include `validUntil < Date.now()` in the
// rejection condition. The cache-hot path in `entitlement-check.ts`
// self-validates `validUntil >= Date.now()` at line 134, but the
// Convex fallback at lines 154-156 does not — without this check
// an entitlement row with stale `validUntil` would pass the gateway
// re-check via the fallback path. Mirror the per-handler runProPreChecks
// and authorize-pro entitlement guards.
const ent = await getEntitlements(verified.userId);
// Single-source Pro MCP decision. The gateway keeps its HTTP denial and
// telemetry contract; the shared gate owns access and billing precedence.
const gate = checkProMcpAccess(ent, Date.now());
const mcpCovered = gate === null;
const billingDenial = denyForBillingVerification(
ent,
corsHeaders,
mcpCovered,
);
if (billingDenial) return billingDenial;
if (!mcpCovered) {
emitRequest(401, 'auth_401', null);
return new Response(
JSON.stringify({ error: 'insufficient_entitlement' }),
{ status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } },
);
}
// Rebuild Request with trusted markers — sanitised header set
// already had inbound copies stripped above, so this is the ONLY
// place those markers can enter the downstream path. Body is
// re-supplied from the bytes we buffered (bodyBytes is null for
// GET/HEAD, in which case we omit the body field entirely).
//
// The verified-marker value is a per-process-startup random nonce,
// NOT the constant '1'. This protects direct edge functions that
// call `isCallerPremium` but don't route through this gateway —
// an attacker can't guess the nonce, so spoofing the marker on
// those endpoints fails closed.
//
// F7 (U7+U8 review pass): strip the inbound HMAC headers BEFORE
// setting the trusted markers. The gateway has consumed them via
// verifyInternalMcpRequest; downstream handlers should only see
// the trusted-marker pair, not the raw signature/userId headers.
// Defense-in-depth — handlers shouldn't have any reason to read
// the inbound HMAC.
const trusted = new Headers(request.headers);
trusted.delete(INTERNAL_MCP_SIG_HEADER);
trusted.delete(INTERNAL_MCP_USER_ID_HEADER);
trusted.delete(INTERNAL_MCP_NONCE_HEADER);
trusted.set(INTERNAL_MCP_VERIFIED_HEADER, getInternalMcpVerifiedNonce());
trusted.set(TRUSTED_USER_ID_HEADER, verified.userId);
const rebuildInit: RequestInit = { method: request.method, headers: trusted };
if (bodyBytes !== null) rebuildInit.body = bodyBytes;
request = new Request(request.url, rebuildInit);
usage.sessionUserId = verified.userId;
recordUsageEntitlement(ent);
internalMcpVerified = true;
}
// Tier gate check first — JWT resolution is expensive (JWKS + RS256) and only needed
// for tier-gated endpoints. Non-tier-gated endpoints never use sessionUserId.
//
// Internal-MCP verified path skips the tier gate / Clerk JWT resolution
// entirely: we already resolved the userId via HMAC verify and confirmed
// tier ≥ 1 + mcpAccess === true. Re-running the JWT path on a request
// that has no Authorization header would just no-op anyway.
// Two high-volume, caller-invariant dashboard reads expose an exact
// `public=1` URL shape. The marker creates a CDN key separate from the
// legacy credentialed URL, which remains session/key gated and no-store.
// Classification ignores attached credentials because a Vercel cache hit
// happens before this function sees them; the public URL must therefore
// have one response contract for every caller.
const isPublicNoAuthRpc = PUBLIC_NO_AUTH_RPC_PATHS.has(pathname)
|| isPublicSharedRpcRequest(request.url, request.method);
const seedRefreshVerified = await isResilienceRankingSeedRefreshRequest(request, pathname);
const relayWarmPingVerified = await isRelayWarmPingRequest(request, pathname);
const requiresDirectLlmQuota = !internalMcpVerified && await shouldReserveGatewayDirectLlmQuota(request, pathname);
const isTierGated = !internalMcpVerified && !isPublicNoAuthRpc && !seedRefreshVerified && !relayWarmPingVerified && getRequiredTier(pathname) !== null;
const needsLegacyProBearerGate = !internalMcpVerified && !isPublicNoAuthRpc && PREMIUM_RPC_PATHS.has(pathname) && !isTierGated;
const isProFreshCacheRpc = PRO_FRESH_CACHE_RPC_PATHS.has(pathname);
const needsProFreshnessResolution =
!internalMcpVerified &&
!isPublicNoAuthRpc &&
isProFreshCacheRpc &&
request.headers.get('Authorization')?.startsWith('Bearer ') === true;
let rateLimitPrincipalUserId: string | undefined;
// Session resolution — extract userId from bearer token (Clerk JWT) if present.
// Runs only for tier gates, direct-LLM quota, or the explicit Pro-fresh
// market allowlist to avoid JWKS lookup on every request.
let sessionUserId: string | null = null;
let sessionRole: 'free' | 'pro' | null = null;
if (isTierGated || requiresDirectLlmQuota || needsProFreshnessResolution) {
const session = await resolveClerkSession(request);
sessionUserId = session?.userId ?? null;
sessionRole = session?.role ?? null;
usage.sessionUserId = sessionUserId;
usage.clerkOrgId = session?.orgId ?? null;
if (sessionUserId) {
request = withAuthenticatedUserId(request, sessionUserId);
}
}
// API key validation — tier-gated endpoints require EITHER an API key OR a valid bearer token.
// Authenticated users (sessionUserId present) bypass the API key requirement.
//
// Internal-MCP verified path: skip validateApiKey entirely. The HMAC
// verify replaced the API key contract for this request — running
// validateApiKey would 401 every Pro tool fetch (no wm_ key on the
// request). Telemetry stays attributed via the verified userId set
// above; entitlement re-check (`features.tier ≥ 1 && mcpAccess`) was
// already performed before flipping `internalMcpVerified = true`.
let keyCheck: { valid: boolean; required: boolean; error?: string; kind?: 'enterprise' | 'session' | 'user' } = internalMcpVerified || isPublicNoAuthRpc || seedRefreshVerified || relayWarmPingVerified
? { valid: true, required: false }
: ((await validateApiKey(request, {
forceKey: (isTierGated && !sessionUserId) || needsLegacyProBearerGate,
})) as { valid: boolean; required: boolean; error?: string; kind?: 'enterprise' | 'session' | 'user' });
// User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS
// check fails, try async Convex-backed validation for user-issued keys.
//
// Run this before the Clerk-session override below. A request can carry both
// a valid bearer session and an X-Api-Key wm_ header; when that happens, the
// wm_ key is still an explicit authenticating credential and its owner must
// pass the #4611 apiAccess gate.
let isUserApiKey = false;
const wmKey =
request.headers.get('X-WorldMonitor-Key') ??
request.headers.get('X-Api-Key') ??
'';
if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) {
// Unknown wm_ credentials require a Convex-backed hash lookup before we
// know the account principal. Bound that unattributed work by IP first:
// otherwise an attacker can rotate syntactically-valid keys and evade the
// per-hash negative cache while every request reaches Convex. The 600/min
// ceiling matches the repo-wide global IP budget and deliberately fails
// closed when Redis is unavailable because this guard protects the auth
// backend itself.
const validationGuardResponse = await checkFailClosedScopedIpRateLimit(
request,
'user-api-key:pre-auth-validation',
600,
'60 s',
corsHeaders,
);
if (validationGuardResponse) {
const reason = getRateLimitTelemetryReason(
validationGuardResponse,
'rate_limit_429',
);
emitRequest(validationGuardResponse.status, reason, null);
return validationGuardResponse;
}
// Only destructure validateUserApiKey: several gateway unit tests mock this
// module with a partial surface. Requiring isUserApiKeyUnavailableError at
// import time breaks those mocks (vitest throws "No export is defined").
// Classify unavailability by the stable `code` field instead.
const { validateUserApiKey } = await import('./_shared/user-api-key');
try {
const userKeyResult = await validateUserApiKey(wmKey);
if (userKeyResult) {
isUserApiKey = true;
usage.isUserApiKey = true;
usage.userApiKeyCustomerRef = userKeyResult.userId;
keyCheck = { valid: true, required: true };
// Propagate the resolved key-owner identity to downstream route
// handlers via x-user-id. The entitlement check itself takes the
// userId argument directly (see checkEntitlement(sessionUserId, …))
// so it no longer depends on this header — the header is now for
// handler consumption + the internal-MCP `isCallerPremium` path.
sessionUserId = userKeyResult.userId;
// The Clerk role belongs to the bearer subject, not the user-key owner.
// Once the explicit wm_ key becomes the identity source, require the
// key owner's Convex entitlement to drive tier-gated access.
sessionRole = null;
usage.sessionUserId = sessionUserId;
usage.clerkOrgId = null;
request = withAuthenticatedUserId(request, sessionUserId);
}
} catch (err) {
// Transient Convex validation outage must not look like an invalid key.
// Mirror api/_user-api-key.js serviceUnavailable() (503 + Retry-After +
// X-Validation-Mode: degraded) so clients retry instead of rotating keys.
// Duck-type on `code` so partial test mocks of user-api-key still work.
const code =
typeof err === 'object' && err !== null
? (err as { code?: unknown }).code
: undefined;
if (code === 'validation_unavailable') {
emitRequest(503, 'validation_unavailable', null);
return new Response(JSON.stringify({ error: 'Service temporarily unavailable' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '5',
'X-Validation-Mode': 'degraded',
...corsHeaders,
},
});
}
throw err;
}
}
// Clerk session is itself proof of authentication (validated at line 410).
// validateApiKey is strict-no-trust-of-headers per #3541 and would 401 every
// Clerk-authenticated user who hasn't also minted a wms_ session token.
// Override: routes that deliberately resolved a sessionUserId pass this layer.
if (
(isTierGated || requiresDirectLlmQuota || needsProFreshnessResolution) &&
sessionUserId &&
keyCheck.required &&
!keyCheck.valid
) {
keyCheck = { valid: true, required: false };
}
// Enterprise API key (WORLDMONITOR_VALID_KEYS): require kind === 'enterprise'.
// Without this, anonymous wms_ tokens slipped through (validateApiKey marks
// them valid, wmKey is set, !isUserApiKey, and 'wms_' doesn't startsWith
// 'wm_'), so telemetry mislabelled them as enterprise_api_key with
// customer_id='enterprise-unmapped'. PR #3557 round-3 review.
if (keyCheck.valid && wmKey && !isUserApiKey && keyCheck.kind === 'enterprise') {
usage.enterpriseApiKey = wmKey;
}
// ── Active-subscription gate for user API keys (#4611) ──────────────────
// A wm_ user key that authenticated this request must map to an owner with
// ACTIVE apiAccess on EVERY keyed route — not just PREMIUM_RPC_PATHS. A
// cancelled/downgraded customer keeps a valid (un-revoked) key that still
// resolves to their userId, so without a route-wide gate the key keeps
// serving the whole paid programmatic surface for free: the API Starter
// product leaks past churn. Runs BEFORE the #3199 per-account rate-limit
// block so an expired key is rejected outright, never metered — and the
// resolved entitlement is reused there to avoid a second lookup.
//
// Scoped to isUserApiKey: the wm_ key IS the authenticating credential
// (isUserApiKey ⇒ sessionUserId is the resolved key owner, set above).
// This intentionally does NOT re-validate wm_ keys on any other route class:
// - Enterprise operator keys (kind 'enterprise', incl. legacy wm_-prefixed
// relay keys) never set isUserApiKey and carry no user entitlement row.
// - Verified internal paths (MCP / seed-refresh / relay warm-ping) never
// set isUserApiKey.
// - PUBLIC_NO_AUTH_RPC_PATHS serve free data to everyone; the key is not
// the authenticator there. Re-validating an arbitrary header key on that
// anonymous surface would add an unauthenticated Convex-lookup
// amplification vector (a rotating fake wm_ key per request defeats the
// negative cache, ahead of any rate limit) for no revenue gain — public
// data is not the paid product — and would wrongly gate the
// intentionally-anonymous lead-capture forms.
let userKeyEntitlement: CachedEntitlements | null | undefined;
if (isUserApiKey && sessionUserId) {
userKeyEntitlement = await getEntitlements(sessionUserId);
recordUsageEntitlement(userKeyEntitlement);
const apiAccessCovered = !!userKeyEntitlement &&
userKeyEntitlement.features.apiAccess &&
(userKeyEntitlement.validUntil ?? 0) >= Date.now();
const billingDenial = denyForBillingVerification(
userKeyEntitlement,
corsHeaders,
apiAccessCovered,
);
if (billingDenial) return billingDenial;
// A validated wm_ key proves key ownership, not current paid access.
// Transient lookup failures now arrive as a verificationUnavailable
// marker and were already answered with the retryable 503 by
// denyForBillingVerification above; a null here means the backend is
// unconfigured or gave a confirmed/malformed answer, and allowing it
// would turn that state into paid API access. Fail closed with a 503
// — EXCEPT when the entitlement backend itself is unconfigured: that is
// a deploy defect, not customer billing state, and 503ing every wm_ key
// fleet-wide would convert a config regression into a total API outage.
// Misconfig serves fail-open (pre-#4770 behavior) and logs loudly.
if (!userKeyEntitlement) {
if (isEntitlementBackendConfigured()) {
emitRequest(503, 'billing_verification_503', null);
return new Response(
JSON.stringify({
error: 'Unable to verify API access',
code: 'entitlement_verification_unavailable',
}),
{
status: 503,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Retry-After': '5',
'X-Billing-Verification': 'entitlement_verification_unavailable',
},
},
);
}
console.error(
'[gateway] entitlement backend unconfigured (CONVEX_SITE_URL / shared secret missing) — serving wm_-key request fail-open',
);
} else if (
!userKeyEntitlement.features.apiAccess ||
(userKeyEntitlement.validUntil ?? 0) < Date.now()
) {
emitRequest(403, 'tier_403', null);
return createGatewayAuthErrorResponse(
403,
'API access requires an active subscription',
corsHeaders,
);
} else {
// A validated user key plus active apiAccess is a trusted paid
// principal even on routes without an endpoint tier policy.
rateLimitPrincipalUserId = sessionUserId;
}
}
// Pro freshness is an optional paid benefit, not an access gate. Resolve
// only identities that were already verified above (Clerk bearer or a
// user-owned API key), then fail closed to the ordinary cache policy when
// entitlement state is absent, expired, or temporarily unavailable.
//
// Do not accept the Clerk role alone here: this contract is specifically
// for active plans, while role='pro' can also represent legacy/test grants.
let hasProFreshCacheAccess = internalMcpVerified && isProFreshCacheRpc;
if (!hasProFreshCacheAccess && isProFreshCacheRpc && sessionUserId) {
const ent =
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId);
recordUsageEntitlement(ent);
hasProFreshCacheAccess =
!!ent &&
ent.features.tier >= 1 &&
ent.validUntil >= Date.now();
if (hasProFreshCacheAccess) {
rateLimitPrincipalUserId = sessionUserId;
}
}
if (keyCheck.required && !keyCheck.valid) {
if (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) {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, 'Invalid or expired session', corsHeaders);
}
// Capture identity for telemetry — legacy bearer auth bypasses the
// earlier resolveClerkSession() block (only runs for tier-gated routes),
// so without this premium bearer requests would emit as anonymous.
if (session.userId) {
sessionUserId = session.userId;
usage.sessionUserId = session.userId;
request = withAuthenticatedUserId(request, session.userId);
}
// 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);
recordUsageEntitlement(ent);
const proCovered = !!ent &&
ent.features.tier >= 1 &&
ent.validUntil >= Date.now();
const billingDenial = denyForBillingVerification(
ent,
corsHeaders,
proCovered,
);
if (billingDenial) return billingDenial;
allowed = !!ent && ent.features.tier >= 1 && ent.validUntil >= Date.now();
}
if (!allowed) {
emitRequest(403, 'tier_403', null);
return createGatewayAuthErrorResponse(403, 'Pro subscription required', corsHeaders);
}
rateLimitPrincipalUserId = session.userId;
// Valid pro session (Clerk role OR Dodo entitlement) — fall through to route handling.
} else {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, keyCheck.error, corsHeaders);
}
} else {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, keyCheck.error, corsHeaders);
}
}
// Entitlement check — blocks tier-gated endpoints for users below required tier.
// Admin API-key holders (WORLDMONITOR_VALID_KEYS, kind: 'enterprise') bypass.
// User API keys do NOT bypass — the key owner's tier is checked normally.
// Anonymous wms_ session tokens (kind: 'session') do NOT bypass — they are
// freely mintable by any caller and are NOT user-bound (PR #3557 review).
//
// Internal-MCP verified path also bypasses: we already confirmed
// tier ≥ 1 + mcpAccess === true above. Some ENDPOINT_ENTITLEMENTS
// routes require tier 2, but Pro MCP callers only reach the gateway
// through the MCP edge's whitelisted tool set.
const isEnterpriseAuth = keyCheck.valid && wmKey && !isUserApiKey && keyCheck.kind === 'enterprise';
if (!isEnterpriseAuth && !internalMcpVerified && !seedRefreshVerified && !relayWarmPingVerified) {
const entitlementCheck = await checkEntitlementDetailed(sessionUserId, pathname, corsHeaders, {
clerkRole: sessionRole,
});
recordUsageEntitlement(entitlementCheck.entitlements);
const entitlementResponse = entitlementCheck.response;
if (entitlementResponse) {
const entReason: RequestReason =
entitlementResponse.status === 401 ? 'auth_401'
: entitlementResponse.status === 403 ? 'tier_403'
: entitlementResponse.status === 503 ? 'billing_verification_503'
: 'ok';
emitRequest(entitlementResponse.status, entReason, null);
return entitlementResponse.status === 401 || entitlementResponse.status === 403
? markAuthErrorNoStore(entitlementResponse)
: entitlementResponse;
}
// A successful tier gate proves this server-derived principal currently
// holds the paid access required by the route. Reuse that authorization
// decision for both endpoint and global limiter attribution so Pro users
// behind a NAT do not share an IP bucket with unrelated traffic.
if (sessionUserId && isTierGated) {
rateLimitPrincipalUserId = sessionUserId;
}
// #5206: summarize refreshes from multiple active Pro users can share a
// NAT/public IP and collectively exhaust the endpoint's 30/min abuse
// bucket. Keep the exact same fail-closed endpoint policy, but isolate
// confirmed active paid principals. Signed-in free, anonymous, expired,
// and unresolvable callers deliberately retain the per-IP bucket.
// requiresDirectLlmQuota intentionally limits this exception to
// spend-bearing summarize requests: translate/malformed requests do not
// spend direct LLM quota and keep ordinary per-IP behavior, while cache
// lookup is handled by its distinct route.
if (
pathname === '/api/news/v1/summarize-article' &&
requiresDirectLlmQuota &&
sessionUserId
) {
// This guard runs before the entitlement lookup needed to choose the
// final endpoint bucket. Its distinct 600/min IP namespace matches the
// repo-wide global ceiling (20x the endpoint's 30/min spend cap): enough
// NAT headroom for legitimate Pro refreshes, while bounding per-IP
// entitlement-I/O amplification and failing closed when Redis degrades.
const attributionGuardResponse = await checkFailClosedScopedIpRateLimit(
request,
'summarize-article:principal-attribution',
600,
'60 s',
corsHeaders,
);
if (attributionGuardResponse) {
const reason = getRateLimitTelemetryReason(
attributionGuardResponse,
'rate_limit_429',
);
emitRequest(attributionGuardResponse.status, reason, null);
return attributionGuardResponse;
}
const ent = entitlementCheck.entitlements ?? (
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId)
);
recordUsageEntitlement(ent);
if (ent && ent.features.tier >= 1 && ent.validUntil >= Date.now()) {
rateLimitPrincipalUserId = sessionUserId;
}
}
}
// Route matching — if POST doesn't match, convert to GET for stale clients
let matchedHandler = router.match(request);
if (!matchedHandler && request.method === 'POST') {
if (isPostToGetCompatibleBodySize(request.headers)) {
const url = new URL(request.url);
let oversizedKey: string | null = null;
try {
const bodyText = await request.clone().text();
if (new TextEncoder().encode(bodyText).byteLength >= POST_TO_GET_MAX_BODY_BYTES) {
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({ error: 'malformed_request' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const body = JSON.parse(bodyText);
const isScalar = (x: unknown): x is string | number | boolean =>
typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean';
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(v)) {
if (v.length > POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY) {
oversizedKey = k;
break;
}
v.forEach((item) => { if (isScalar(item)) url.searchParams.append(k, String(item)); });
} else if (isScalar(v)) url.searchParams.set(k, String(v));
}
} catch { /* non-JSON body — preserve legacy POST→GET fallback */ }
if (oversizedKey !== null) {
emitRequest(400, 'malformed_request', null);
return new Response(JSON.stringify({
error: 'Too many values for POST compatibility parameter',
parameter: oversizedKey,
maxValues: POST_TO_GET_MAX_ARRAY_VALUES_PER_KEY,
}), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const getReq = new Request(url.toString(), { method: 'GET', headers: request.headers });
matchedHandler = router.match(getReq);
if (matchedHandler) request = getReq;
}
}
if (!matchedHandler) {
const allowed = router.allowedMethods(new URL(request.url).pathname);
if (allowed.length > 0) {
emitRequest(405, 'method_not_allowed', null);
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', Allow: allowed.join(', '), ...corsHeaders },
});
}
emitRequest(404, 'unknown_route', null);
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const requiredBboxDiagnostic = getRequiredBboxDiagnostic(request, pathname);
const identityForScope = buildUsageIdentity(usage);
// ── Idempotency-Key support (mutation retry-safety) ──────────────────────
// Opt-in: only a POST carrying the header. POST→GET-converted batch reads
// (compat block above) are already GET here and are skipped. Scope by the
// resolved principal so a key can never replay another caller's response.
// Fail-open: any Redis issue proceeds without idempotency (see the module).
let idempotency: IdempotencyOutcome | null = null;
const hasIdempotencyKey = request.method === 'POST' && request.headers.has(IDEMPOTENCY_HEADER);
const idScope = identityForScope.principal_id ?? identityForScope.customer_id;
const idempotencyScope = idScope ? `${identityForScope.auth_kind}:${idScope}` : null;
// Look up an existing idempotency record before rate-limit/quota counters.
// This lets a retry of completed work replay without charging a duplicate
// unit. A miss does NOT claim the key; fresh executions still pass through
// the normal abuse controls before `beginIdempotency()` below.
if (hasIdempotencyKey) {
const peek = await peekIdempotency({
request,
pathname,
scope: idempotencyScope,
idempotencyKey: request.headers.get(IDEMPOTENCY_HEADER) ?? '',
corsHeaders,
});
switch (peek.kind) {
case 'invalid':
emitRequest(400, 'idempotency_invalid', null);
return peek.response;
case 'replay':
emitRequest(peek.response.status, 'idempotent_replay', null);
return peek.response;
case 'conflict':
emitRequest(409, 'idempotency_conflict', null);
return peek.response;
case 'mismatch':
emitRequest(422, 'idempotency_mismatch', null);
return peek.response;
// 'miss' proceeds to rate limiting; 'disabled' preserves fail-open behavior.
}
}
// Gateway rate limiting — two-phase: endpoint-specific first, then global fallback.
// Confirmed paid principals use per-user buckets; other traffic uses IP.
//
// Internal-MCP verified requests skip this gateway layer: the MCP edge
// already enforced 50/day + 60/min per userId in api/mcp.ts. A second
// limiter here would create misleading double-counting and could 429
// legitimate Pro tool fetches that pass the upstream cap.
if (!internalMcpVerified) {
const endpointRlResponse = rateLimitPrincipalUserId
? await checkEndpointRateLimit(request, pathname, corsHeaders, {
principalUserId: rateLimitPrincipalUserId,
})
: await checkEndpointRateLimit(request, pathname, corsHeaders);
if (endpointRlResponse) {
const reason = getRateLimitTelemetryReason(
endpointRlResponse,
'rate_limit_429_endpoint',
);
emitRequest(endpointRlResponse.status, reason, null);
return endpointRlResponse;
}
// ── Per-account API rate limit (#3199) ──────────────────────────────
// Eligible authenticated keys — a valid user key (which carries NO
// keyCheck.kind, so `isUserApiKey` is the discriminator) or an enterprise
// env key — are governed by a per-account burst + daily meter (enforced
// at the sold allowance, #4635) instead of the global fallback. In ENFORCE
// they bypass that fallback below; in SHADOW they only record telemetry
// and still fall through to it. Validated user keys use their trusted
// principal there, while enterprise keys retain IP attribution.
// Limits are NOT in scope here (checkEntitlement discards `features`), so
// user keys resolve getEntitlements explicitly (cached); enterprise keys
// carry no entitlement and use hardcoded limits.
let governedByApiKeyLayer = false;
if (keyCheck.valid && (isUserApiKey || isEnterpriseAuth)) {
const enforce = process.env.API_RATE_LIMIT_ENFORCE === 'true';
let perMinute = 0;
let allowance = -1;
let identity = '';
let planKey = ''; // #4635 — hoisted for the informative 429 (ent is block-scoped below)
if (isEnterpriseAuth) {
perMinute = ENTERPRISE_API_RATE_LIMIT; // hardcoded — no entitlement row
allowance = -1; // unlimited daily / no ceiling
planKey = 'enterprise'; // top tier — named in the 429, but no upgrade_url
usage.tier = 3; // enterprise tier — no entitlement row to read it from
// (plan_key defaults to 'enterprise' in buildUsageIdentity)
// Enterprise burst is keyed PER KEY (not per account) by design:
// these are operator-issued WORLDMONITOR_VALID_KEYS with no shared
// userId, and unlimited daily — so there's no quota to multiply by
// minting keys, and each operator key gets its own 1,000/min budget
// rather than contending for one shared bucket. (User keys below key
// on userId so a customer can't multiply their allowance.)
identity = wmKey ? hashKeySync(wmKey) : '';
} else if (sessionUserId) {
// Reuse the entitlement the #4611 gate above already resolved for this
// same user key (undefined ⇒ the gate didn't run, e.g. a Clerk-session
// caller with no wm_ key — resolve it now). Avoids a duplicate lookup
// on the hot active-key path.
const ent =
userKeyEntitlement !== undefined
? userKeyEntitlement
: await getEntitlements(sessionUserId);
if (ent) {
// #4572 — attribute the usage event to the caller's real tier +
// plan (recorded even for downgraded keys), so the limit-abuse
// audit can compare each request to the customer's actual cap.
recordUsageEntitlement(ent);
}
if (ent && ent.features.apiAccess && ent.features.apiRateLimit > 0) {
perMinute = ent.features.apiRateLimit;
// undefined ⇒ fail-open (no daily limit); -1 ⇒ unlimited.
allowance =
typeof ent.features.apiDailyAllowance === 'number'
? ent.features.apiDailyAllowance
: -1;
planKey = ent.planKey;
identity = sessionUserId;
}
// else: downgraded / null entitlement ⇒ not eligible (perMinute = 0),
// falls through to the per-IP path — never a slidingWindow(0).
}
if (perMinute > 0 && identity) {
// #4635 — informative 429 upgrade link; omitted for enterprise/top tier.
const upgradeUrl =
planKey && planKey !== 'enterprise' ? 'https://worldmonitor.app/' : undefined;
// 1. Per-minute burst (hard limit).
const burst = await checkBurst(perMinute, identity);
if (!burst.ok) {
if (enforce) {
const retryAfterSec = Math.max(1, Math.ceil((burst.reset - Date.now()) / 1000));
emitRequest(429, 'rl_min_429', null);
return new Response(JSON.stringify({
error: 'Too many requests',
plan: planKey || undefined,
limit: burst.limit,
limit_type: 'per_minute',
reset: new Date(burst.reset).toISOString(),
upgrade_url: upgradeUrl,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...rateLimitHeaders({ limit: burst.limit, remaining: 0, resetMs: burst.reset, retryAfterSec, windowSec: 60 }),
...corsHeaders,
},
});
}
pendingShadowReason = 'rl_min_shadow';
} else if (allowance >= 0) {
// 2. Daily meter — hard-rejects at the sold allowance (#4635).
// Skipped for unlimited (-1); reserveDailyMeter fail-opens on <=0.
const meter = await reserveDailyMeter({
userId: identity,
allowance,
pipeline: (cmds) => runRedisPipeline(cmds),
});
if (meter.overLimit) {
if (enforce) {
await meter.rollback();
emitRequest(429, 'rl_ceiling_429', null);
return new Response(JSON.stringify({
error: 'Daily request limit reached',
plan: planKey || undefined,
limit: allowance,
limit_type: 'daily',
reset: new Date(Date.now() + meter.retryAfterSec * 1000).toISOString(),
upgrade_url: upgradeUrl,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...rateLimitHeaders({
limit: allowance,
remaining: 0,
resetMs: Date.now() + meter.retryAfterSec * 1000,
retryAfterSec: meter.retryAfterSec,
// Daily ceiling window (24 h) for the advertised policy.
windowSec: 86_400,
}),
...corsHeaders,
},
});
}
pendingShadowReason = 'rl_ceiling_shadow';
}
}
// Eligible + enforce + not rejected ⇒ the per-account layer governs
// this request and skips the global fallback. In shadow, keep that
// fallback active: validated user keys use their trusted principal,
// while enterprise keys retain IP attribution.
if (enforce) governedByApiKeyLayer = true;
}
}
if (!governedByApiKeyLayer && !hasEndpointRatePolicy(pathname)) {
const rateLimitResponse = rateLimitPrincipalUserId
? await checkRateLimit(request, corsHeaders, {
principalUserId: rateLimitPrincipalUserId,
})
: await checkRateLimit(request, corsHeaders);
if (rateLimitResponse) {
const reason = getRateLimitTelemetryReason(
rateLimitResponse,
'rate_limit_429_global',
);
emitRequest(rateLimitResponse.status, reason, null);
return rateLimitResponse;
}
}
}
if (requiresDirectLlmQuota && !isEnterpriseAuth) {
if (!sessionUserId) {
emitRequest(401, 'auth_401', null);
return createGatewayAuthErrorResponse(401, 'Pro authentication required', corsHeaders);
}
const reservation = await reserveDirectLlmQuota({
userId: sessionUserId,
pipeline: (cmds) => runRedisPipeline(cmds, true),
});
if (!reservation.ok) {
const response = createDirectLlmQuotaFailureResponse(reservation, corsHeaders);
emitRequest(
response.status,
response.status === 429 ? 'rate_limit_429_direct_llm' : 'rate_limit_degraded',
null,
);
return response;
}
}
// Gate on presence (not truthiness) so a present-but-empty header is
// rejected as malformed rather than silently ignored.
if (hasIdempotencyKey) {
idempotency = await beginIdempotency({
request,
pathname,
// Tag the scope with the auth kind so value spaces (Clerk id vs hashed
// key vs customer ref) can never collide across authentication methods.
scope: idempotencyScope,
idempotencyKey: request.headers.get(IDEMPOTENCY_HEADER) ?? '',
corsHeaders,
});
switch (idempotency.kind) {
case 'invalid':
emitRequest(400, 'idempotency_invalid', null);
return idempotency.response;
case 'replay':
emitRequest(idempotency.response.status, 'idempotent_replay', null);
return idempotency.response;
case 'conflict':
emitRequest(409, 'idempotency_conflict', null);
return idempotency.response;
case 'mismatch':
emitRequest(422, 'idempotency_mismatch', null);
return idempotency.response;
// 'disabled' (fail-open) and 'proceed' fall through to execution.
}
}
// Execute handler with top-level error boundary.
// Wrap in runWithUsageScope so deep fetch helpers (fetchJson,
// cachedFetchJsonWithMeta) can attribute upstream calls to this customer
// without leaf handlers having to thread a usage hook through every call.
let response: Response;
const handlerCall = matchedHandler;
const requestForHandler = request;
try {
response = await runWithUsageScope(
{
ctx: ctx ?? { waitUntil: () => {} },
requestId: deriveRequestId(originalRequest),
customerId: identityForScope.customer_id,
route: pathname,
tier: identityForScope.tier,
},
() => handlerCall(requestForHandler),
);
} catch (err) {
console.error('[gateway] Unhandled handler error:', err);
response = new Response(JSON.stringify({ message: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Merge CORS + handler side-channel headers into response
const mergedHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
mergedHeaders.set(key, value);
}
const extraHeaders = drainResponseHeaders(request);
if (extraHeaders) {
for (const [key, value] of Object.entries(extraHeaders)) {
mergedHeaders.set(key, value);
}
}
const retryableResponse = drainRetryableResponse(request);
attachRequiredBboxDiagnosticHeaders(mergedHeaders, pathname, requiredBboxDiagnostic);
// Handler side-channel status override (setSuccessStatusOverride): applied
// only when the handler actually produced a 200 on a POST — async-enqueue
// endpoints (run-scenario) upgrade their success to 202 Accepted, while
// thrown ApiError statuses always win. GET success flows are excluded:
// the ETag/304 + CDN-cache path below assumes 200. Always drained so a
// set-but-unapplied override can't leak state.
const statusOverride = drainSuccessStatusOverride(request);
const finalStatus =
statusOverride !== undefined && request.method === 'POST' && response.status === 200
? statusOverride
: response.status;
// For GET 200 responses: read body once for cache-header decisions + ETag
let resolvedCacheTier: CacheTier | null = null;
if (response.status === 200 && request.method === 'GET' && response.body) {
const bodyBytes = await response.arrayBuffer();
const bodyStr = new TextDecoder().decode(bodyBytes);
const noStoreReason = getRpcNoStoreReasonFromJson(bodyStr, { pathname });
if (mergedHeaders.get('X-No-Cache') || noStoreReason) {
mergedHeaders.set('Cache-Control', 'no-store');
mergedHeaders.delete('CDN-Cache-Control');
mergedHeaders.delete('Vercel-CDN-Cache-Control');
mergedHeaders.set('X-Cache-Tier', 'no-store');
resolvedCacheTier = 'no-store';
} else {
const rpcName = pathname.split('/').pop() ?? '';
const envOverride = process.env[`CACHE_TIER_OVERRIDE_${rpcName.replace(/-/g, '_').toUpperCase()}`] as CacheTier | undefined;
const isPremium = PREMIUM_RPC_PATHS.has(pathname) || getRequiredTier(pathname) !== null;
const hasCredentialedNonPublicGet = !isPublicNoAuthRpc && hasCredentialBearingHeader(request);
const tier = hasProFreshCacheAccess ? 'live-browser' as CacheTier
: isPremium || hasCredentialedNonPublicGet ? 'slow-browser' as CacheTier
: (envOverride && envOverride in TIER_HEADERS ? envOverride : null) ?? RPC_CACHE_TIER[pathname] ?? 'medium';
resolvedCacheTier = tier;
mergedHeaders.set('Cache-Control', TIER_HEADERS[tier]);
// Only allow Vercel CDN caching for trusted origins (worldmonitor.app, Vercel previews,
// Tauri). No-origin server-side requests (external scrapers) must always reach the edge
// function so the auth check in validateApiKey() can run. Without this guard, a cached
// 200 from a trusted-origin browser request could be served to a no-origin scraper,
// bypassing auth entirely.
const reqOrigin = request.headers.get('origin') || '';
const cdnCache = !hasProFreshCacheAccess && !isPremium && !hasCredentialedNonPublicGet && isAllowedOrigin(reqOrigin)
? TIER_CDN_CACHE[tier]
: null;
mergedHeaders.delete('CDN-Cache-Control');
mergedHeaders.delete('Vercel-CDN-Cache-Control');
if (cdnCache) mergedHeaders.set('CDN-Cache-Control', cdnCache);
mergedHeaders.set('X-Cache-Tier', tier);
// Keep per-origin ACAO (already set from corsHeaders above) and preserve Vary: Origin.
// ACAO: * with no Vary would collapse all origins into one cache entry, bypassing
// isDisallowedOrigin() for cache hits — Vercel CDN serves s-maxage responses without
// re-invoking the function, so a disallowed origin could read a cached ACAO: * response.
}
mergedHeaders.delete('X-No-Cache');
if (!new URL(request.url).searchParams.has('_debug')) {
mergedHeaders.delete('X-Cache-Tier');
}
// Universal optional JMESPath projection (REST parity with the MCP
// server's `jmespath` tool argument). Applied to the JSON body BEFORE the
// ETag hash so the ETag reflects the projected payload; the ?jmespath=
// expression is part of the request URL, so Vercel's CDN keys each
// projection separately. GET-only: mutating POSTs are already fully typed
// via their requestBody and their responses are not cached/ETagged here.
// See server/_shared/response-projection.ts + /docs/mcp-jmespath.
let responseView = new Uint8Array(bodyBytes);
const jmespathExpr = new URL(request.url).searchParams.get('jmespath');
if (jmespathExpr && (mergedHeaders.get('Content-Type') ?? '').includes('application/json')) {
const projection = projectJsonResponse(bodyStr, jmespathExpr);
if (!projection.ok) {
const errorBody = JSON.stringify(projection.envelope);
emitRequest(400, 'malformed_request', null, errorBody.length);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(errorBody, {
status: 400,
headers: {
...corsHeaders,
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
},
});
}
responseView = new TextEncoder().encode(projection.body);
// The projected body has a different length than the handler's — drop any
// stale Content-Length so the runtime recomputes it (a leftover value
// would truncate the response).
mergedHeaders.delete('Content-Length');
}
// FNV-1a inspired fast hash — good enough for cache validation
let hash = 2166136261;
const view = responseView;
for (let i = 0; i < view.length; i++) {
hash ^= view[i]!;
hash = Math.imul(hash, 16777619);
}
const etag = `"${(hash >>> 0).toString(36)}-${view.length.toString(36)}"`;
mergedHeaders.set('ETag', etag);
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === etag) {
emitRequest(304, 'ok', resolvedCacheTier, 0);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(null, { status: 304, headers: mergedHeaders });
}
emitRequest(response.status, 'ok', resolvedCacheTier, view.length);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(responseView, {
status: response.status,
statusText: response.statusText,
headers: mergedHeaders,
});
}
if (response.status === 200 && request.method === 'GET') {
if (mergedHeaders.get('X-No-Cache')) {
mergedHeaders.set('Cache-Control', 'no-store');
}
mergedHeaders.delete('X-No-Cache');
}
// Idempotent POST (opt-in): buffer the body so it can be persisted for
// replay, then echo the key. Only reached when the client sent a valid
// Idempotency-Key on a first request; normal POSTs keep the streaming path
// below untouched.
if (idempotency?.kind === 'proceed') {
const bodyBytes = response.body ? await response.arrayBuffer() : new ArrayBuffer(0);
mergedHeaders.set(IDEMPOTENCY_HEADER, idempotency.key);
mergedHeaders.set(IDEMPOTENT_REPLAYED_HEADER, 'false');
// Awaited (not waitUntil'd) so a sub-second retry sees the completed
// record rather than a lingering 'processing' lock → 409. store() is
// best-effort/fail-open, so a Redis blip degrades to a re-executable
// retry, never a failed response.
// Generated response-envelope RPCs can report a retryable ServiceError
// inside HTTP 200. Feed store() a retryable status only for its
// persist-vs-release decision; the client still receives finalStatus.
await idempotency.store(
retryableResponse ? 503 : finalStatus,
bodyBytes,
response.headers.get('content-type'),
);
emitRequest(finalStatus, 'ok', resolvedCacheTier, bodyBytes.byteLength);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(bodyBytes, {
status: finalStatus,
statusText: response.statusText,
headers: mergedHeaders,
});
}
// Streaming/non-GET-200 responses: res_bytes is best-effort 0 (Content-Length
// is often absent on chunked responses; teeing the stream would add latency).
const finalContentLen = response.headers.get('content-length');
const finalResBytes = finalContentLen ? Number(finalContentLen) || 0 : 0;
emitRequest(finalStatus, 'ok', resolvedCacheTier, finalResBytes);
maybeAttachDevHealthHeader(mergedHeaders);
return new Response(response.body, {
status: finalStatus,
statusText: response.statusText,
headers: mergedHeaders,
});
};
}