import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { validateApiKey } from './_api-key.js'; import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; const BOOTSTRAP_CACHE_KEYS = { earthquakes: 'seismology:earthquakes:v1', outages: 'infra:outages:v1', serviceStatuses: 'infra:service-statuses:v1', marketQuotes: 'market:stocks-bootstrap:v1', commodityQuotes: 'market:commodities-bootstrap:v1', sectors: 'market:sectors:v1', etfFlows: 'market:etf-flows:v1', macroSignals: 'economic:macro-signals:v1', bisPolicy: 'economic:bis:policy:v1', bisExchange: 'economic:bis:eer:v1', bisCredit: 'economic:bis:credit:v1', shippingRates: 'supply_chain:shipping:v2', chokepoints: 'supply_chain:chokepoints:v4', chokepointTransits: 'supply_chain:chokepoint_transits:v1', minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', radiationWatch: 'radiation:observations:v1', wildfires: 'wildfire:fires:v1', cyberThreats: 'cyber:threats-bootstrap:v2', techReadiness: 'economic:worldbank-techreadiness:v1', progressData: 'economic:worldbank-progress:v1', renewableEnergy: 'economic:worldbank-renewable:v1', positiveGeoEvents: 'positive_events:geo-bootstrap:v1', theaterPosture: 'theater_posture:sebuf:stale:v1', riskScores: 'risk:scores:sebuf:stale:v1', naturalEvents: 'natural:events:v1', flightDelays: 'aviation:delays-bootstrap:v1', insights: 'news:insights:v1', predictions: 'prediction:markets-bootstrap:v1', cryptoQuotes: 'market:crypto:v1', gulfQuotes: 'market:gulf-quotes:v1', stablecoinMarkets: 'market:stablecoins:v1', unrestEvents: 'unrest:events:v1', iranEvents: 'conflict:iran-events:v1', ucdpEvents: 'conflict:ucdp-events:v1', temporalAnomalies: 'temporal:anomalies:v1', weatherAlerts: 'weather:alerts:v1', spending: 'economic:spending:v1', techEvents: 'research:tech-events-bootstrap:v1', gdeltIntel: 'intelligence:gdelt-intel:v1', correlationCards: 'correlation:cards-bootstrap:v1', forecasts: 'forecast:predictions:v2', securityAdvisories: 'intelligence:advisories-bootstrap:v1', customsRevenue: 'trade:customs-revenue:v1', sanctionsPressure: 'sanctions:pressure:v1', }; const SLOW_KEYS = new Set([ 'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving', 'sectors', 'etfFlows', 'wildfires', 'climateAnomalies', 'radiationWatch', 'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy', 'naturalEvents', 'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents', 'techEvents', 'securityAdvisories', 'customsRevenue', 'sanctionsPressure', ]); const FAST_KEYS = new Set([ 'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', 'chokepointTransits', 'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions', 'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture', 'gdeltIntel', 'correlationCards', 'forecasts', 'shippingRates', ]); const TIER_CACHE = { slow: 'public, s-maxage=3600, stale-while-revalidate=600, stale-if-error=3600', fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', }; const TIER_CDN_CACHE = { slow: 'public, s-maxage=7200, stale-while-revalidate=1800, stale-if-error=7200', fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', }; const NEG_SENTINEL = '__WM_NEG__'; async function getCachedJsonBatch(keys) { const result = new Map(); if (keys.length === 0) return result; const url = process.env.UPSTASH_REDIS_REST_URL; const token = process.env.UPSTASH_REDIS_REST_TOKEN; if (!url || !token) return result; // Always read unprefixed keys — bootstrap is a read-only consumer of // production cache data. Preview/branch deploys don't run handlers that // populate prefixed keys, so prefixing would always miss. const pipeline = keys.map((k) => ['GET', k]); const resp = await fetch(`${url}/pipeline`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(pipeline), signal: AbortSignal.timeout(3000), }); if (!resp.ok) return result; const data = await resp.json(); for (let i = 0; i < keys.length; i++) { const raw = data[i]?.result; if (raw) { try { const parsed = JSON.parse(raw); if (parsed !== NEG_SENTINEL) result.set(keys[i], parsed); } catch { /* skip malformed */ } } } return result; } export default async function handler(req) { if (isDisallowedOrigin(req)) return new Response('Forbidden', { status: 403 }); const cors = getCorsHeaders(req); if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors }); const apiKeyResult = validateApiKey(req); if (apiKeyResult.required && !apiKeyResult.valid) return jsonResponse({ error: apiKeyResult.error }, 401, cors); const url = new URL(req.url); const tier = url.searchParams.get('tier'); let registry; if (tier === 'slow' || tier === 'fast') { const tierSet = tier === 'slow' ? SLOW_KEYS : FAST_KEYS; registry = Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => tierSet.has(k))); } else { const requested = url.searchParams.get('keys')?.split(',').filter(Boolean).sort(); registry = requested ? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k))) : BOOTSTRAP_CACHE_KEYS; } const keys = Object.values(registry); const names = Object.keys(registry); let cached; try { cached = await getCachedJsonBatch(keys); } catch { return jsonResponse({ data: {}, missing: names }, 200, { ...cors, 'Cache-Control': 'no-cache' }); } const data = {}; const missing = []; for (let i = 0; i < names.length; i++) { const val = cached.get(keys[i]); if (val !== undefined) data[names[i]] = val; else missing.push(names[i]); } const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900'; return jsonResponse({ data, missing }, 200, { ...cors, 'Cache-Control': cacheControl, 'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast, }); }