mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(supply-chain): Sprint 1 — Route Explorer wrapper RPC
Adds an internal wrapper around the vendor-only route-intelligence
compute so the upcoming Route Explorer UI can call it from a browser
PRO session instead of forcing an X-WorldMonitor-Key API gate.
Backend:
- New proto get-route-explorer-lane.proto with GetRouteExplorerLane{Request,Response}
- New handler server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts
- New static lookup tables _route-explorer-static-tables.ts:
TRANSIT_DAYS_BY_ROUTE_ID, FREIGHT_USD_BY_CARGO_TYPE,
BYPASS_CORRIDOR_GEOMETRY_BY_ID — covers all 5 land-bridge corridors
plus every sea-alternative corridor with hand-curated coordinates
- Wired into supply-chain handler.ts service dispatcher
- Cache key ROUTE_EXPLORER_LANE_KEY in cache-keys.ts (NOT in BOOTSTRAP_KEYS)
- Gateway entry: PREMIUM_RPC_PATHS + RPC_CACHE_TIER 'slow-browser'
- Premium path entry in src/shared/premium-paths.ts so browser PRO auth attaches
Response contract enriches route-intelligence with:
- primaryRouteGeometry polyline from TRADE_ROUTES (lon/lat pairs)
- fromPort/toPort coords on every bypass option so the client can call
MapContainer.setBypassRoutes directly without geometry lookups
- status: 'active' | 'proposed' | 'unavailable' derived from corridor notes
to honestly label kra_canal_future and black_sea_western_ports
- estTransitDaysRange + estFreightUsdPerTeuRange from static tables
- noModeledLane: true when origin/destination clusters share no routes
Client wrapper fetchRouteExplorerLane added to src/services/supply-chain/index.ts.
Tests: tests/route-explorer-lane.test.mts — 30-query smoke matrix
(10 country pairs × 3 HS2 codes), structural assertions only, no
hard-coded transit/cost values. Test exposes a pure computeLane()
function with an injectable status map so it does not need Redis.
Gap report (from smoke run): 12 of 30 queries fall back to a synthetic
primaryRouteId because the destination's port cluster has no shared route
with the origin (US-JP, ZA-IN, CL-CN, TR-DE × 3 HS2 each). These pairs
return noModeledLane:true; Sprint 3 will render an empty-state for them.
Plan: docs/plans/2026-04-11-001-feat-worldwide-route-explorer-plan.md
* fix(route-explorer): address PR #2980 review findings
P1: bypass warRiskTier was hard-coded to WAR_RISK_TIER_NORMAL, dropping
the live risk signal from chokepoint status. Now derived from the
statusMap via the corridor's primaryChokepointId.
P2: freight fallback in emptyResponse and client-side empty payload used
a cargo-agnostic container range for all cargo types. Removed the ranges
entirely from fallback/noModeledLane responses; they are only present
when the lane is actually modeled.
Suggestion: when noModeledLane is true, the response now returns empty
primaryRouteId, empty geometry, empty exposures, empty bypasses, and
omits transit/freight ranges. Previously it returned plausible-looking
synthetic data from the origin's first route which could mislead the UI.
Tests updated to assert the noModeledLane contract: empty fields when
the flag is set, non-empty ranges only when the lane is modeled.
* fix(route-explorer): cargo-aware route ranking + bypass waypoint risk
P1: primary route selection was order-dependent, picking whichever
shared route the origin cluster listed first. Mixed clusters like
CN/JP could return an energy lane for a container request. Now ranks
shared routes by cargo-category compatibility (container→container,
tanker→energy, bulk→bulk, roro→container) before selecting.
P1: bypass warRiskTier was copied from the primary chokepoint instead
of derived from the corridor's own waypointChokepointIds. This
overstated risk for alternatives like Cape of Good Hope whose waypoints
may have a lower risk tier. Now uses max-tier across waypoint
chokepoints, matching get-bypass-options.ts logic.
Suggestion: placeholder corridors with addedTransitDays=0 (like
gibraltar_no_bypass, cape_of_good_hope_is_bypass) are now filtered out.
Previously they could surface as active alternatives.
Regression tests added:
- CN→JP tanker: asserts energy route is selected over container route
- CN→DE with faked Suez=CRITICAL / Cape=NORMAL: asserts Cape bypass
shows NORMAL, not CRITICAL
- ES→EG: asserts zero-transit-day placeholders are excluded
* fix(route-explorer): scope exposures to primary route + narrow placeholder filter
P1: chokepointExposures and bypassOptions were computed from the full
sharedRoutes set, mixing data from energy/container corridors into a
single response. Now scoped to the cargo-ranked primaryRouteId only,
matching the proto contract that exposures are "on the primary route."
P2: the addedTransitDays === 0 filter was too broad and removed
kra_canal_future (a proposed bypass with real modeling). Narrowed to an
explicit PLACEHOLDER_CORRIDOR_IDS set (gibraltar_no_bypass,
cape_of_good_hope_is_bypass) so proposed zero-day corridors survive and
are surfaced with CORRIDOR_STATUS_PROPOSED.
Regression tests:
- chokepointExposures follow primaryRouteId (CN->JP container)
- kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes
- placeholder filter still excludes explicit placeholders
* fix(route-explorer): address PR #2980 review comments
1. Unavailable corridors without waypoints (e.g. black_sea_western_ports)
now derive WAR_RISK_TIER_WAR_ZONE from their CORRIDOR_STATUS_UNAVAILABLE
status, instead of returning WAR_RISK_TIER_UNSPECIFIED. Corridors with
waypointChokepointIds still use max-tier across those waypoints.
2. Added fixture test with non-empty status map (suez=75/HIGH,
malacca=30/ELEVATED) so disruptionScore and warRiskTier assertions are
not trivially satisfied by the empty-map default path.
3. Documented the single-chokepoint bypass design gap in the test gap report:
bypassOptions only cover the primary chokepoint; multi-chokepoint routes
show exposure for all but bypass guidance for only the top one. Sprint 3
will decide whether to expand to top-N or add a UI hint.
503 lines
24 KiB
TypeScript
503 lines
24 KiB
TypeScript
/**
|
||
* 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';
|
||
// @ts-expect-error — JS module, no declaration file
|
||
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 { resolveSessionUserId } from './_shared/auth-session';
|
||
import type { ServerOptions } from '../src/generated/server/worldmonitor/seismology/v1/service_server';
|
||
|
||
export const serverOptions: ServerOptions = { onError: mapErrorToResponse };
|
||
|
||
// --- 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' | 'static' | 'daily' | 'no-store';
|
||
|
||
// 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.
|
||
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',
|
||
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',
|
||
};
|
||
|
||
// 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',
|
||
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,
|
||
};
|
||
|
||
const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||
'/api/maritime/v1/get-vessel-snapshot': 'no-store',
|
||
|
||
'/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/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-shipping-rates': 'daily',
|
||
'/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',
|
||
'/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/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/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-insider-transactions': 'slow',
|
||
'/api/economic/v1/get-economic-calendar': 'slow',
|
||
'/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-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-sector-dependency': 'slow-browser',
|
||
'/api/supply-chain/v1/get-route-explorer-lane': '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',
|
||
'/api/resilience/v1/get-resilience-score': 'slow',
|
||
'/api/resilience/v1/get-resilience-ranking': 'slow',
|
||
};
|
||
|
||
import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths';
|
||
|
||
/**
|
||
* 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 function createDomainGateway(
|
||
routes: RouteDescriptor[],
|
||
): (req: Request) => Promise<Response> {
|
||
const router = createRouter(routes);
|
||
|
||
return async function handler(originalRequest: Request): Promise<Response> {
|
||
let request = originalRequest;
|
||
const rawPathname = new URL(request.url).pathname;
|
||
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') : rawPathname;
|
||
|
||
// Origin check — skip CORS headers for disallowed origins
|
||
if (isDisallowedOrigin(request)) {
|
||
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
||
status: 403,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
let corsHeaders: Record<string, string>;
|
||
try {
|
||
corsHeaders = getCorsHeaders(request);
|
||
} catch {
|
||
corsHeaders = { 'Access-Control-Allow-Origin': '*' };
|
||
}
|
||
|
||
// OPTIONS preflight
|
||
if (request.method === 'OPTIONS') {
|
||
return new Response(null, { status: 204, headers: corsHeaders });
|
||
}
|
||
|
||
// Tier gate check first — JWT resolution is expensive (JWKS + RS256) and only needed
|
||
// for tier-gated endpoints. Non-tier-gated endpoints never use sessionUserId.
|
||
const isTierGated = getRequiredTier(pathname) !== null;
|
||
const needsLegacyProBearerGate = PREMIUM_RPC_PATHS.has(pathname) && !isTierGated;
|
||
|
||
// Session resolution — extract userId from bearer token (Clerk JWT) if present.
|
||
// Only runs for tier-gated endpoints to avoid JWKS lookup on every request.
|
||
let sessionUserId: string | null = null;
|
||
if (isTierGated) {
|
||
sessionUserId = await resolveSessionUserId(request);
|
||
if (sessionUserId) {
|
||
request = new Request(request.url, {
|
||
method: request.method,
|
||
headers: (() => {
|
||
const h = new Headers(request.headers);
|
||
h.set('x-user-id', sessionUserId);
|
||
return h;
|
||
})(),
|
||
body: request.body,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
const keyCheck = validateApiKey(request, {
|
||
forceKey: (isTierGated && !sessionUserId) || needsLegacyProBearerGate,
|
||
});
|
||
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) {
|
||
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||
});
|
||
}
|
||
if (session.role !== 'pro') {
|
||
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
|
||
} else {
|
||
return new Response(JSON.stringify({ error: keyCheck.error, _debug: (keyCheck as any)._debug }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||
});
|
||
}
|
||
} else {
|
||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
// Valid API-key holders bypass entitlement checks (they have full access by virtue
|
||
// of possessing a key). Only bearer-token users go through the tier gate.
|
||
if (!(keyCheck.valid && request.headers.get('X-WorldMonitor-Key'))) {
|
||
const entitlementResponse = await checkEntitlement(request, pathname, corsHeaders);
|
||
if (entitlementResponse) return entitlementResponse;
|
||
}
|
||
|
||
// IP-based rate limiting — two-phase: endpoint-specific first, then global fallback
|
||
const endpointRlResponse = await checkEndpointRateLimit(request, pathname, corsHeaders);
|
||
if (endpointRlResponse) return endpointRlResponse;
|
||
|
||
if (!hasEndpointRatePolicy(pathname)) {
|
||
const rateLimitResponse = await checkRateLimit(request, corsHeaders);
|
||
if (rateLimitResponse) return rateLimitResponse;
|
||
}
|
||
|
||
// Route matching — if POST doesn't match, convert to GET for stale clients
|
||
let matchedHandler = router.match(request);
|
||
if (!matchedHandler && request.method === 'POST') {
|
||
const contentLen = parseInt(request.headers.get('Content-Length') ?? '0', 10);
|
||
if (contentLen < 1_048_576) {
|
||
const url = new URL(request.url);
|
||
try {
|
||
const body = await request.clone().json();
|
||
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)) 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 — skip POST→GET conversion */ }
|
||
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) {
|
||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||
status: 405,
|
||
headers: { 'Content-Type': 'application/json', Allow: allowed.join(', '), ...corsHeaders },
|
||
});
|
||
}
|
||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||
status: 404,
|
||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||
});
|
||
}
|
||
|
||
// Execute handler with top-level error boundary
|
||
let response: Response;
|
||
try {
|
||
response = await matchedHandler(request);
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
// For GET 200 responses: read body once for cache-header decisions + ETag
|
||
if (response.status === 200 && request.method === 'GET' && response.body) {
|
||
const bodyBytes = await response.arrayBuffer();
|
||
|
||
// Skip CDN caching for upstream-unavailable / empty responses so CF
|
||
// doesn't serve stale error data for hours.
|
||
const bodyStr = new TextDecoder().decode(bodyBytes);
|
||
const isUpstreamUnavailable = bodyStr.includes('"upstreamUnavailable":true');
|
||
|
||
if (mergedHeaders.get('X-No-Cache') || isUpstreamUnavailable) {
|
||
mergedHeaders.set('Cache-Control', 'no-store');
|
||
mergedHeaders.set('X-Cache-Tier', '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 tier = isPremium ? 'slow-browser' as CacheTier
|
||
: (envOverride && envOverride in TIER_HEADERS ? envOverride : null) ?? RPC_CACHE_TIER[pathname] ?? 'medium';
|
||
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 = !isPremium && isAllowedOrigin(reqOrigin) ? TIER_CDN_CACHE[tier] : null;
|
||
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');
|
||
}
|
||
|
||
// FNV-1a inspired fast hash — good enough for cache validation
|
||
let hash = 2166136261;
|
||
const view = new Uint8Array(bodyBytes);
|
||
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) {
|
||
return new Response(null, { status: 304, headers: mergedHeaders });
|
||
}
|
||
|
||
return new Response(bodyBytes, {
|
||
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');
|
||
}
|
||
|
||
return new Response(response.body, {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
headers: mergedHeaders,
|
||
});
|
||
};
|
||
}
|