From a742537ae5266e93f5dabfd972ff7c59cd66a89a Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 10 Apr 2026 17:12:29 +0400 Subject: [PATCH] =?UTF-8?q?feat(supply-chain):=20Sprint=20D=20=E2=80=94=20?= =?UTF-8?q?GetSectorDependency=20RPC=20+=20vendor=20route-intelligence=20A?= =?UTF-8?q?PI=20+=20webhooks=20(#2905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(supply-chain): Sprint D — GetSectorDependency RPC + vendor route-intelligence API + webhooks * fix(supply-chain): move bypass-corridors + chokepoint-registry to server/_shared to fix api/ boundary violations * fix(supply-chain): webhooks — persist secret, fix sub-resource routing, add ownership check * fix(supply-chain): address PR #2905 review findings - Use SHA-256(apiKey) for ownerTag instead of last-12-chars (unambiguous ownership) - Implement GET /api/v2/shipping/webhooks list route via per-owner Redis Set index - Tighten SSRF: https-only, expanded metadata hostname blocklist, document DNS rebinding edge-runtime limitation - Fix get-sector-dependency.ts stale src/config/ imports → server/_shared/ (Greptile P1) * fix(supply-chain): getSectorDependency returns blank primaryChokepointId for landlocked countries computeExposures() previously mapped over all of CHOKEPOINT_REGISTRY even when nearestRouteIds was empty, producing a full array of score-0 entries in registry insertion order. The caller's exposures[0] then picked the first registry entry (Suez) as the "primary" chokepoint despite primaryChokepointExposure = 0. LI, AD, SM, BT and other landlocked countries were all silently assigned a fake chokepoint. Fix: guard at the top of computeExposures() -- return [] when input is empty so primaryChokepointId stays '' and primaryChokepointExposure stays 0. --- api/v2/shipping/route-intelligence.ts | 146 +++++++ api/v2/shipping/webhooks.ts | 353 ++++++++++++++++ docs/api/SupplyChainService.openapi.json | 2 +- docs/api/SupplyChainService.openapi.yaml | 90 ++++ ...-supply-chain-routing-intelligence-plan.md | 2 +- .../v1/get_sector_dependency.proto | 52 +++ .../supply_chain/v1/service.proto | 6 + server/_shared/bypass-corridors.ts | 389 ++++++++++++++++++ server/_shared/cache-keys.ts | 7 + server/_shared/chokepoint-registry.ts | 217 ++++++++++ server/gateway.ts | 1 + .../supply-chain/v1/get-sector-dependency.ts | 186 +++++++++ .../worldmonitor/supply-chain/v1/handler.ts | 2 + .../supply_chain/v1/service_client.ts | 46 +++ .../supply_chain/v1/service_server.ts | 69 ++++ 15 files changed, 1566 insertions(+), 2 deletions(-) create mode 100644 api/v2/shipping/route-intelligence.ts create mode 100644 api/v2/shipping/webhooks.ts create mode 100644 proto/worldmonitor/supply_chain/v1/get_sector_dependency.proto create mode 100644 server/_shared/bypass-corridors.ts create mode 100644 server/_shared/chokepoint-registry.ts create mode 100644 server/worldmonitor/supply-chain/v1/get-sector-dependency.ts diff --git a/api/v2/shipping/route-intelligence.ts b/api/v2/shipping/route-intelligence.ts new file mode 100644 index 000000000..527a8fe2b --- /dev/null +++ b/api/v2/shipping/route-intelligence.ts @@ -0,0 +1,146 @@ +/** + * GET /api/v2/shipping/route-intelligence + * + * Vendor-facing route intelligence API. Returns the primary trade route, chokepoint + * exposures, bypass options, war risk tier, and disruption score for a given + * country pair + cargo type. + * + * Authentication: X-WorldMonitor-Key required (forceKey: true). Browser origins + * are NOT exempt — this endpoint is designed for server-to-server integration. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../_api-key.js'; +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders } from '../../_cors.js'; +import { isCallerPremium } from '../../../server/_shared/premium-check'; +import { getCachedJson } from '../../../server/_shared/redis'; +import { CHOKEPOINT_STATUS_KEY } from '../../../server/_shared/cache-keys'; +import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../server/_shared/bypass-corridors'; +import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry'; +import COUNTRY_PORT_CLUSTERS from '../../../scripts/shared/country-port-clusters.json'; + +interface PortClusterEntry { + nearestRouteIds: string[]; + coastSide: string; +} + +interface ChokepointStatus { + id: string; + name?: string; + disruptionScore?: number; + warRiskTier?: string; +} + +interface ChokepointStatusResponse { + chokepoints?: ChokepointStatus[]; +} + +export default async function handler(req: Request): Promise { + const cors = getCorsHeaders(req); + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + if (req.method !== 'GET') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + + const apiKeyResult = validateApiKey(req, { forceKey: true }); + if (apiKeyResult.required && !apiKeyResult.valid) { + return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { + status: 401, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const isPro = await isCallerPremium(req); + if (!isPro) { + return new Response(JSON.stringify({ error: 'PRO subscription required' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const { searchParams } = new URL(req.url); + const fromIso2 = searchParams.get('fromIso2')?.trim().toUpperCase() ?? ''; + const toIso2 = searchParams.get('toIso2')?.trim().toUpperCase() ?? ''; + const cargoType = (searchParams.get('cargoType')?.trim().toLowerCase() ?? 'container') as 'container' | 'tanker' | 'bulk' | 'roro'; + const hs2 = searchParams.get('hs2')?.trim().replace(/\D/g, '') || '27'; + + if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) { + return new Response(JSON.stringify({ error: 'fromIso2 and toIso2 must be valid 2-letter ISO country codes' }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record; + const fromCluster = clusters[fromIso2]; + const toCluster = clusters[toIso2]; + + const fromRoutes = new Set(fromCluster?.nearestRouteIds ?? []); + const toRoutes = new Set(toCluster?.nearestRouteIds ?? []); + const sharedRoutes = [...fromRoutes].filter(r => toRoutes.has(r)); + const primaryRouteId = sharedRoutes[0] ?? fromCluster?.nearestRouteIds[0] ?? ''; + + // Load live chokepoint data + const statusRaw = await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as ChokepointStatusResponse | null; + const statusMap = new Map( + (statusRaw?.chokepoints ?? []).map(cp => [cp.id, cp]) + ); + + // Find chokepoints on the primary route and shared routes + const relevantRouteSet = new Set(sharedRoutes.length ? sharedRoutes : (fromCluster?.nearestRouteIds ?? [])); + const chokepointExposures = CHOKEPOINT_REGISTRY + .filter(cp => cp.routeIds.some(r => relevantRouteSet.has(r))) + .map(cp => { + const overlap = cp.routeIds.filter(r => relevantRouteSet.has(r)).length; + const exposurePct = Math.round((overlap / Math.max(cp.routeIds.length, 1)) * 100); + return { chokepointId: cp.id, chokepointName: cp.displayName, exposurePct }; + }) + .filter(e => e.exposurePct > 0) + .sort((a, b) => b.exposurePct - a.exposurePct); + + const primaryChokepoint = chokepointExposures[0]; + const primaryCpStatus = primaryChokepoint ? statusMap.get(primaryChokepoint.chokepointId) : null; + + const disruptionScore = primaryCpStatus?.disruptionScore ?? 0; + const warRiskTier = primaryCpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL'; + + // Bypass options for the primary chokepoint + const corridors = primaryChokepoint + ? (BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepoint.chokepointId] ?? []) + .filter(c => c.suitableCargoTypes.length === 0 || c.suitableCargoTypes.includes(cargoType)) + .slice(0, 5) + .map(c => ({ + id: c.id, + name: c.name, + type: c.type, + addedTransitDays: c.addedTransitDays, + addedCostMultiplier: c.addedCostMultiplier, + activationThreshold: c.activationThreshold, + })) + : []; + + const body = { + fromIso2, + toIso2, + cargoType, + hs2, + primaryRouteId, + chokepointExposures, + bypassOptions: corridors, + warRiskTier, + disruptionScore, + fetchedAt: new Date().toISOString(), + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60, stale-while-revalidate=120' }, + }); +} diff --git a/api/v2/shipping/webhooks.ts b/api/v2/shipping/webhooks.ts new file mode 100644 index 000000000..fa10ac8f3 --- /dev/null +++ b/api/v2/shipping/webhooks.ts @@ -0,0 +1,353 @@ +/** + * POST /api/v2/shipping/webhooks — Register a webhook for chokepoint disruption alerts. + * GET /api/v2/shipping/webhooks — List webhooks for the authenticated caller. + * + * Payload: { callbackUrl, chokepointIds[], alertThreshold } + * Response: { subscriberId, secret } + * + * Security: + * - X-WorldMonitor-Key required (forceKey: true) + * - SSRF prevention: callbackUrl hostname is validated against private IP ranges. + * LIMITATION: DNS rebinding is not mitigated in the edge runtime (no DNS resolution + * at registration time). The delivery worker MUST resolve the URL before sending and + * re-check it against PRIVATE_HOSTNAME_PATTERNS. HTTPS-only is required to limit + * exposure (TLS certs cannot be issued for private IPs via public CAs). + * - HMAC signatures: webhook deliveries include X-WM-Signature: sha256= + * - Ownership: SHA-256 of the caller's API key is stored as ownerTag; an owner index (Redis Set) + * enables list queries without a full scan. + */ + +export const config = { runtime: 'edge' }; + +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../_api-key.js'; +// @ts-expect-error — JS module, no declaration file +import { getCorsHeaders } from '../../_cors.js'; +import { isCallerPremium } from '../../../server/_shared/premium-check'; +import { getCachedJson, setCachedJson, runRedisPipeline } from '../../../server/_shared/redis'; +import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry'; + +const WEBHOOK_TTL = 86400 * 30; // 30 days +const VALID_CHOKEPOINT_IDS = new Set(CHOKEPOINT_REGISTRY.map(c => c.id)); + +// Private IP ranges + known cloud metadata hostnames blocked at registration. +// NOTE: DNS rebinding bypass is not mitigated here (no DNS resolution in edge runtime). +// The delivery worker must re-validate the resolved IP before sending. +const PRIVATE_HOSTNAME_PATTERNS = [ + /^localhost$/i, + /^127\.\d+\.\d+\.\d+$/, + /^10\.\d+\.\d+\.\d+$/, + /^192\.168\.\d+\.\d+$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, + /^169\.254\.\d+\.\d+$/, // link-local + AWS/GCP/Azure IMDS + /^fd[0-9a-f]{2}:/i, // IPv6 ULA (fd00::/8) + /^fe80:/i, // IPv6 link-local + /^::1$/, // IPv6 loopback + /^0\.0\.0\.0$/, + /^0\.\d+\.\d+\.\d+$/, // RFC 1122 "this network" + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/, // RFC 6598 shared address +]; + +// Known cloud metadata endpoints that must be blocked explicitly even if the +// IP regex above misses a future alias or IPv6 variant. +const BLOCKED_METADATA_HOSTNAMES = new Set([ + '169.254.169.254', // AWS/Azure/GCP IMDS (IPv4) + 'metadata.google.internal', // GCP metadata server + 'metadata.internal', // GCP alternative alias + 'instance-data', // OpenStack metadata + 'metadata', // generic cloud metadata alias + 'computemetadata', // GCP legacy + 'link-local.s3.amazonaws.com', +]); + +function isBlockedCallbackUrl(rawUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return 'callbackUrl is not a valid URL'; + } + + // HTTPS is required — TLS certs cannot be issued for private IPs via public CAs, + // which prevents the most common DNS-rebinding variant in practice. + if (parsed.protocol !== 'https:') { + return 'callbackUrl must use https'; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (BLOCKED_METADATA_HOSTNAMES.has(hostname)) { + return 'callbackUrl hostname is a blocked metadata endpoint'; + } + + for (const pattern of PRIVATE_HOSTNAME_PATTERNS) { + if (pattern.test(hostname)) { + return `callbackUrl resolves to a private/reserved address: ${hostname}`; + } + } + + return null; +} + +async function generateSecret(): Promise { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function generateSubscriberId(): string { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return 'wh_' + [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function webhookKey(subscriberId: string): string { + return `webhook:sub:${subscriberId}:v1`; +} + +function ownerIndexKey(ownerHash: string): string { + return `webhook:owner:${ownerHash}:v1`; +} + +/** SHA-256 hash of the caller's API key — used as ownerTag and owner index key. Never secret. */ +async function callerFingerprint(req: Request): Promise { + const key = req.headers.get('X-WorldMonitor-Key') ?? ''; + if (!key) return 'anon'; + const encoded = new TextEncoder().encode(key); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); + return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +interface WebhookRecord { + subscriberId: string; + ownerTag: string; // SHA-256 hash of the registrant's API key for ownership checks + callbackUrl: string; + chokepointIds: string[]; + alertThreshold: number; + createdAt: string; + active: boolean; + // secret is persisted so delivery workers can sign payloads via HMAC-SHA256. + // Stored in trusted Redis; rotated via /rotate-secret. + secret: string; +} + +export default async function handler(req: Request): Promise { + const cors = getCorsHeaders(req); + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + const apiKeyResult = validateApiKey(req, { forceKey: true }); + if (apiKeyResult.required && !apiKeyResult.valid) { + return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), { + status: 401, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const isPro = await isCallerPremium(req); + if (!isPro) { + return new Response(JSON.stringify({ error: 'PRO subscription required' }), { + status: 403, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const url = new URL(req.url); + const pathParts = url.pathname.replace(/\/+$/, '').split('/'); + + // Find the wh_* segment anywhere in the path (handles /webhooks/wh_xxx/action) + const whIndex = pathParts.findIndex(p => p.startsWith('wh_')); + const subscriberId = whIndex !== -1 ? pathParts[whIndex] : null; + // Action is the segment after the wh_* segment, if present + const action = whIndex !== -1 ? (pathParts[whIndex + 1] ?? null) : null; + + // POST /api/v2/shipping/webhooks — Register new webhook + if (req.method === 'POST' && !subscriberId) { + let body: { callbackUrl?: string; chokepointIds?: string[]; alertThreshold?: number }; + try { + body = await req.json() as typeof body; + } catch { + return new Response(JSON.stringify({ error: 'Request body must be valid JSON' }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const { callbackUrl, chokepointIds = [], alertThreshold = 50 } = body; + + if (!callbackUrl) { + return new Response(JSON.stringify({ error: 'callbackUrl is required' }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const ssrfError = isBlockedCallbackUrl(callbackUrl); + if (ssrfError) { + return new Response(JSON.stringify({ error: ssrfError }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const invalidCp = chokepointIds.find(id => !VALID_CHOKEPOINT_IDS.has(id)); + if (invalidCp) { + return new Response(JSON.stringify({ error: `Unknown chokepoint ID: ${invalidCp}` }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + if (typeof alertThreshold !== 'number' || alertThreshold < 0 || alertThreshold > 100) { + return new Response(JSON.stringify({ error: 'alertThreshold must be a number between 0 and 100' }), { + status: 400, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const ownerTag = await callerFingerprint(req); + const newSubscriberId = generateSubscriberId(); + const secret = await generateSecret(); + + const record: WebhookRecord = { + subscriberId: newSubscriberId, + ownerTag, + callbackUrl, + chokepointIds: chokepointIds.length ? chokepointIds : [...VALID_CHOKEPOINT_IDS], + alertThreshold, + createdAt: new Date().toISOString(), + active: true, + secret, // persisted so delivery workers can compute HMAC signatures + }; + + // Persist record + update owner index (Redis Set) atomically via pipeline. + // raw = false so all keys are prefixed consistently with getCachedJson reads. + await runRedisPipeline([ + ['SET', webhookKey(newSubscriberId), JSON.stringify(record), 'EX', String(WEBHOOK_TTL)], + ['SADD', ownerIndexKey(ownerTag), newSubscriberId], + ['EXPIRE', ownerIndexKey(ownerTag), String(WEBHOOK_TTL)], + ]); + + return new Response(JSON.stringify({ subscriberId: newSubscriberId, secret }), { + status: 201, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + // Helper: load record + verify ownership in one place + async function loadOwned(subId: string): Promise { + const record = await getCachedJson(webhookKey(subId)).catch(() => null) as WebhookRecord | null; + if (!record) return 'not_found'; + const ownerHash = await callerFingerprint(req); + if (record.ownerTag !== ownerHash) return 'forbidden'; + return record; + } + + // GET /api/v2/shipping/webhooks — List caller's webhooks + if (req.method === 'GET' && !subscriberId) { + const ownerHash = await callerFingerprint(req); + const smembersResult = await runRedisPipeline([['SMEMBERS', ownerIndexKey(ownerHash)]]); + const memberIds = (smembersResult[0]?.result as string[] | null) ?? []; + + if (memberIds.length === 0) { + return new Response(JSON.stringify({ webhooks: [] }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + const getResults = await runRedisPipeline(memberIds.map(id => ['GET', webhookKey(id)])); + const webhooks = getResults + .map((r) => { + if (!r.result || typeof r.result !== 'string') return null; + try { + const record = JSON.parse(r.result) as WebhookRecord; + if (record.ownerTag !== ownerHash) return null; // defensive ownership check + return { + subscriberId: record.subscriberId, + callbackUrl: record.callbackUrl, + chokepointIds: record.chokepointIds, + alertThreshold: record.alertThreshold, + createdAt: record.createdAt, + active: record.active, + }; + } catch { + return null; + } + }) + .filter(Boolean); + + return new Response(JSON.stringify({ webhooks }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + // GET /api/v2/shipping/webhooks/{subscriberId} — Status check + if (req.method === 'GET' && subscriberId && !action) { + const result = await loadOwned(subscriberId); + if (result === 'not_found') { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + if (result === 'forbidden') { + return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + + return new Response(JSON.stringify({ + subscriberId: result.subscriberId, + callbackUrl: result.callbackUrl, + chokepointIds: result.chokepointIds, + alertThreshold: result.alertThreshold, + createdAt: result.createdAt, + active: result.active, + // secret is intentionally omitted from status responses + }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + // POST /api/v2/shipping/webhooks/{subscriberId}/rotate-secret + if (req.method === 'POST' && subscriberId && action === 'rotate-secret') { + const result = await loadOwned(subscriberId); + if (result === 'not_found') { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + if (result === 'forbidden') { + return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + + const newSecret = await generateSecret(); + await setCachedJson(webhookKey(subscriberId), { ...result, secret: newSecret }, WEBHOOK_TTL); + + return new Response(JSON.stringify({ subscriberId, secret: newSecret, rotatedAt: new Date().toISOString() }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + // POST /api/v2/shipping/webhooks/{subscriberId}/reactivate + if (req.method === 'POST' && subscriberId && action === 'reactivate') { + const result = await loadOwned(subscriberId); + if (result === 'not_found') { + return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + if (result === 'forbidden') { + return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } }); + } + + await setCachedJson(webhookKey(subscriberId), { ...result, active: true }, WEBHOOK_TTL); + + return new Response(JSON.stringify({ subscriberId, active: true }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); +} diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index 5bab64629..20b90575d 100644 --- a/docs/api/SupplyChainService.openapi.json +++ b/docs/api/SupplyChainService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"primaryChokepointWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"supplyDeficitPct":{"description":"Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only)","format":"double","type":"number"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file +{"components":{"schemas":{"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"primaryChokepointWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"supplyDeficitPct":{"description":"Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only)","format":"double","type":"number"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetSectorDependencyRequest":{"properties":{"hs2":{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","hs2"],"type":"object"},"GetSectorDependencyResponse":{"properties":{"fetchedAt":{"type":"string"},"flags":{"items":{"description":"DependencyFlag classifies how a country+sector dependency can fail.","enum":["DEPENDENCY_FLAG_UNSPECIFIED","DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL","DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL","DEPENDENCY_FLAG_COMPOUND_RISK","DEPENDENCY_FLAG_DIVERSIFIABLE"],"type":"string"},"type":"array"},"hasViableBypass":{"description":"Whether at least one viable bypass corridor exists for the primary chokepoint.","type":"boolean"},"hs2":{"type":"string"},"hs2Label":{"description":"Human-readable HS2 chapter name.","type":"string"},"iso2":{"type":"string"},"primaryChokepointExposure":{"description":"Exposure score for the primary chokepoint (0–100).","format":"double","type":"number"},"primaryChokepointId":{"description":"Chokepoint ID with the highest exposure score for this country+sector.","type":"string"},"primaryExporterIso2":{"description":"ISO2 of the country supplying the largest share of this sector's imports.","type":"string"},"primaryExporterShare":{"description":"Share of imports from the primary exporter (0–1). 0 = no Comtrade data available.","format":"double","type":"number"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-sector-dependency":{"get":{"description":"GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated.","operationId":"GetSectorDependency","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\" (mineral fuels), \"85\" (electronics)","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSectorDependencyResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetSectorDependency","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file diff --git a/docs/api/SupplyChainService.openapi.yaml b/docs/api/SupplyChainService.openapi.yaml index bbdcaf54d..c6de683c3 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -231,6 +231,44 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/supply-chain/v1/get-sector-dependency: + get: + tags: + - SupplyChainService + summary: GetSectorDependency + description: GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated. + operationId: GetSectorDependency + parameters: + - name: iso2 + in: query + required: false + schema: + type: string + - name: hs2 + in: query + description: HS2 chapter code, e.g. "27" (mineral fuels), "85" (electronics) + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetSectorDependencyResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: schemas: Error: @@ -793,3 +831,55 @@ components: description: Null/unavailable explanation for non-energy sectors fetchedAt: type: string + GetSectorDependencyRequest: + type: object + properties: + iso2: + type: string + pattern: ^[A-Z]{2}$ + hs2: + type: string + description: HS2 chapter code, e.g. "27" (mineral fuels), "85" (electronics) + required: + - iso2 + - hs2 + GetSectorDependencyResponse: + type: object + properties: + iso2: + type: string + hs2: + type: string + hs2Label: + type: string + description: Human-readable HS2 chapter name. + flags: + type: array + items: + type: string + enum: + - DEPENDENCY_FLAG_UNSPECIFIED + - DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL + - DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL + - DEPENDENCY_FLAG_COMPOUND_RISK + - DEPENDENCY_FLAG_DIVERSIFIABLE + description: DependencyFlag classifies how a country+sector dependency can fail. + primaryExporterIso2: + type: string + description: ISO2 of the country supplying the largest share of this sector's imports. + primaryExporterShare: + type: number + format: double + description: Share of imports from the primary exporter (0–1). 0 = no Comtrade data available. + primaryChokepointId: + type: string + description: Chokepoint ID with the highest exposure score for this country+sector. + primaryChokepointExposure: + type: number + format: double + description: Exposure score for the primary chokepoint (0–100). + hasViableBypass: + type: boolean + description: Whether at least one viable bypass corridor exists for the primary chokepoint. + fetchedAt: + type: string diff --git a/docs/plans/2026-04-09-001-feat-worldwide-supply-chain-routing-intelligence-plan.md b/docs/plans/2026-04-09-001-feat-worldwide-supply-chain-routing-intelligence-plan.md index b5ad4c19c..2ea307e11 100644 --- a/docs/plans/2026-04-09-001-feat-worldwide-supply-chain-routing-intelligence-plan.md +++ b/docs/plans/2026-04-09-001-feat-worldwide-supply-chain-routing-intelligence-plan.md @@ -11,7 +11,7 @@ origin: docs/brainstorms/2026-04-09-worldwide-shipping-intelligence-requirements | Sprint | Scope | PR | Status | |---|---|---|---| | 0–2 | Backend: bypass corridors, exposure seeder, chokepoint index | — | ✅ Merged | -| A | Supply Chain Panel UI: bypass cards, sector exposure, war risk badges | — | ⏳ Not started | +| A | Supply Chain Panel UI: bypass cards, sector exposure, war risk badges | #2896 | 🔁 Review | | B | Map Arc Intelligence: disruption-score arc coloring + arc click popup | — | ⏳ Not started | | C | Scenario Engine: templates, job API, Railway worker, map activation | #2890 | 🔁 Review — ready to merge | | D | Sector Dependency RPC + Vendor API + Sprint C visual deferrals | — | ⏳ Not started | diff --git a/proto/worldmonitor/supply_chain/v1/get_sector_dependency.proto b/proto/worldmonitor/supply_chain/v1/get_sector_dependency.proto new file mode 100644 index 000000000..f4a6fbc62 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_sector_dependency.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; +package worldmonitor.supply_chain.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// DependencyFlag classifies how a country+sector dependency can fail. +enum DependencyFlag { + DEPENDENCY_FLAG_UNSPECIFIED = 0; + // >80% of imports come from a single exporter country. + DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL = 1; + // >80% of imports transit a single chokepoint with no viable bypass route. + DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL = 2; + // Both SINGLE_SOURCE_CRITICAL and SINGLE_CORRIDOR_CRITICAL apply. + DEPENDENCY_FLAG_COMPOUND_RISK = 3; + // Viable bypass exists AND multiple exporters are available. + DEPENDENCY_FLAG_DIVERSIFIABLE = 4; +} + +message GetSectorDependencyRequest { + string iso2 = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = {name: "iso2"} + ]; + // HS2 chapter code, e.g. "27" (mineral fuels), "85" (electronics) + string hs2 = 2 [ + (buf.validate.field).required = true, + (sebuf.http.query) = {name: "hs2"} + ]; +} + +message GetSectorDependencyResponse { + string iso2 = 1; + string hs2 = 2; + // Human-readable HS2 chapter name. + string hs2_label = 3; + // One or more dependency flags; empty means no critical dependency identified. + repeated DependencyFlag flags = 4; + // ISO2 of the country supplying the largest share of this sector's imports. + string primary_exporter_iso2 = 5; + // Share of imports from the primary exporter (0–1). 0 = no Comtrade data available. + double primary_exporter_share = 6; + // Chokepoint ID with the highest exposure score for this country+sector. + string primary_chokepoint_id = 7; + // Exposure score for the primary chokepoint (0–100). + double primary_chokepoint_exposure = 8; + // Whether at least one viable bypass corridor exists for the primary chokepoint. + bool has_viable_bypass = 9; + string fetched_at = 10; +} diff --git a/proto/worldmonitor/supply_chain/v1/service.proto b/proto/worldmonitor/supply_chain/v1/service.proto index 5f2cbe7c4..b9736eb25 100644 --- a/proto/worldmonitor/supply_chain/v1/service.proto +++ b/proto/worldmonitor/supply_chain/v1/service.proto @@ -10,6 +10,7 @@ import "worldmonitor/supply_chain/v1/get_shipping_stress.proto"; import "worldmonitor/supply_chain/v1/get_country_chokepoint_index.proto"; import "worldmonitor/supply_chain/v1/get_bypass_options.proto"; import "worldmonitor/supply_chain/v1/get_country_cost_shock.proto"; +import "worldmonitor/supply_chain/v1/get_sector_dependency.proto"; service SupplyChainService { option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"}; @@ -45,4 +46,9 @@ service SupplyChainService { rpc GetCountryCostShock(GetCountryCostShockRequest) returns (GetCountryCostShockResponse) { option (sebuf.http.config) = {path: "/get-country-cost-shock", method: HTTP_METHOD_GET}; } + + // GetSectorDependency returns dependency flags and risk profile for a country+HS2 sector. PRO-gated. + rpc GetSectorDependency(GetSectorDependencyRequest) returns (GetSectorDependencyResponse) { + option (sebuf.http.config) = {path: "/get-sector-dependency", method: HTTP_METHOD_GET}; + } } diff --git a/server/_shared/bypass-corridors.ts b/server/_shared/bypass-corridors.ts new file mode 100644 index 000000000..74cf914df --- /dev/null +++ b/server/_shared/bypass-corridors.ts @@ -0,0 +1,389 @@ +export type BypassType = 'alternative_sea_route' | 'land_bridge' | 'modal_shift' | 'pipeline'; +export type CargoType = 'container' | 'tanker' | 'bulk' | 'roro'; +export type ActivationThreshold = 'partial_closure' | 'full_closure'; + +export interface BypassCorridor { + id: string; + name: string; + primaryChokepointId: string; + type: BypassType; + waypointChokepointIds: string[]; + addedTransitDays: number; + addedCostMultiplier: number; + capacityConstraintTonnage: number | null; + suitableCargoTypes: CargoType[]; + activationThreshold: ActivationThreshold; + notes: string; +} + +export const BYPASS_CORRIDORS: readonly BypassCorridor[] = [ + // ── Suez Canal bypasses ──────────────────────────────────────────────── + { + id: 'suez_cape_of_good_hope', + name: 'Cape of Good Hope Route', + primaryChokepointId: 'suez', + type: 'alternative_sea_route', + waypointChokepointIds: ['cape_of_good_hope'], + addedTransitDays: 12, + addedCostMultiplier: 1.18, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Primary diversion route for Asia-Europe traffic avoiding the Red Sea', + }, + { + id: 'sumed_pipeline', + name: 'SUMED Pipeline', + primaryChokepointId: 'suez', + type: 'pipeline', + waypointChokepointIds: [], + addedTransitDays: 2, + addedCostMultiplier: 1.05, + capacityConstraintTonnage: 210, + suitableCargoTypes: ['tanker'], + activationThreshold: 'partial_closure', + notes: 'Suez-Mediterranean Pipeline; crude only; 210 Mt/yr capacity', + }, + + // ── Strait of Hormuz bypasses ───────────────────────────────────────── + { + id: 'hormuz_cape_of_good_hope', + name: 'Cape of Good Hope Route', + primaryChokepointId: 'hormuz_strait', + type: 'alternative_sea_route', + waypointChokepointIds: ['cape_of_good_hope'], + addedTransitDays: 16, + addedCostMultiplier: 1.25, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Long-haul diversion bypassing Gulf and Indian Ocean choke via the Cape', + }, + { + id: 'aqaba_land_bridge', + name: 'Aqaba Land Bridge', + primaryChokepointId: 'hormuz_strait', + type: 'land_bridge', + waypointChokepointIds: [], + addedTransitDays: 5, + addedCostMultiplier: 1.35, + capacityConstraintTonnage: 15, + suitableCargoTypes: ['container', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Road/rail transit via Jordan to the port of Aqaba; 15 Mt/yr capacity constraint', + }, + { + id: 'btc_pipeline', + name: 'BTC Pipeline (Baku-Tbilisi-Ceyhan)', + primaryChokepointId: 'hormuz_strait', + type: 'pipeline', + waypointChokepointIds: [], + addedTransitDays: 3, + addedCostMultiplier: 1.1, + capacityConstraintTonnage: 28, + suitableCargoTypes: ['tanker'], + activationThreshold: 'partial_closure', + notes: 'Crude oil pipeline from Caspian to Turkish Mediterranean; 28 Mt/yr capacity', + }, + + // ── Strait of Malacca bypasses ──────────────────────────────────────── + { + id: 'lombok_strait_bypass', + name: 'Lombok Strait', + primaryChokepointId: 'malacca_strait', + type: 'alternative_sea_route', + waypointChokepointIds: ['lombok_strait'], + addedTransitDays: 2, + addedCostMultiplier: 1.05, + capacityConstraintTonnage: null, + suitableCargoTypes: ['tanker', 'bulk'], + activationThreshold: 'partial_closure', + notes: 'Preferred tanker and bulk diversion for vessels too large for Malacca', + }, + { + id: 'sunda_strait', + name: 'Sunda Strait', + primaryChokepointId: 'malacca_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.03, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Narrower and shallower than Lombok; suitable for most vessel classes', + }, + { + id: 'kra_canal_future', + name: 'Kra Canal (Proposed)', + primaryChokepointId: 'malacca_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 0, + addedCostMultiplier: 0.95, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'full_closure', + notes: 'Proposed; not yet constructed', + }, + + // ── Bab el-Mandeb bypasses ──────────────────────────────────────────── + { + id: 'bab_el_mandeb_cape_of_good_hope', + name: 'Cape of Good Hope Route', + primaryChokepointId: 'bab_el_mandeb', + type: 'alternative_sea_route', + waypointChokepointIds: ['cape_of_good_hope'], + addedTransitDays: 10, + addedCostMultiplier: 1.15, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Standard diversion for vessels avoiding the Red Sea / Houthi threat zone', + }, + { + id: 'djibouti_rail', + name: 'Djibouti-Addis Ababa Railway', + primaryChokepointId: 'bab_el_mandeb', + type: 'land_bridge', + waypointChokepointIds: [], + addedTransitDays: 7, + addedCostMultiplier: 1.45, + capacityConstraintTonnage: 1, + suitableCargoTypes: ['container'], + activationThreshold: 'full_closure', + notes: 'Containerised cargo only; 1 Mt/yr capacity; requires full closure to justify costs', + }, + + // ── Bosporus Strait bypasses ────────────────────────────────────────── + { + id: 'btc_pipeline_black_sea', + name: 'BTC Pipeline (Black Sea crude egress)', + primaryChokepointId: 'bosphorus', + type: 'pipeline', + waypointChokepointIds: [], + addedTransitDays: 2, + addedCostMultiplier: 1.08, + capacityConstraintTonnage: 28, + suitableCargoTypes: ['tanker'], + activationThreshold: 'partial_closure', + notes: 'Crude oil pipeline from Baku; avoids tanker transit through the Bosphorus', + }, + { + id: 'baku_tbilisi_batumi_rail', + name: 'Baku-Tbilisi-Batumi Rail Corridor', + primaryChokepointId: 'bosphorus', + type: 'land_bridge', + waypointChokepointIds: [], + addedTransitDays: 4, + addedCostMultiplier: 1.3, + capacityConstraintTonnage: 8, + suitableCargoTypes: ['container', 'bulk'], + activationThreshold: 'partial_closure', + notes: 'Multimodal corridor via Georgia to Black Sea port of Batumi; 8 Mt/yr capacity', + }, + + // ── Panama Canal bypasses ───────────────────────────────────────────── + { + id: 'panama_cape_horn', + name: 'Cape Horn Route', + primaryChokepointId: 'panama', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 22, + addedCostMultiplier: 1.4, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'full_closure', + notes: 'Historically significant; high seas and extreme weather make it a last resort', + }, + { + id: 'us_rail_landbridge', + name: 'US Rail Land Bridge', + primaryChokepointId: 'panama', + type: 'land_bridge', + waypointChokepointIds: [], + addedTransitDays: 6, + addedCostMultiplier: 1.55, + capacityConstraintTonnage: 2, + suitableCargoTypes: ['container'], + activationThreshold: 'partial_closure', + notes: 'Intermodal rail across the continental US; 2 Mt/yr capacity; containers only', + }, + + // ── Taiwan Strait bypasses ──────────────────────────────────────────── + { + id: 'bashi_channel', + name: 'Bashi Channel', + primaryChokepointId: 'taiwan_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.04, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Deep-water channel between Taiwan and the Philippines; suitable for all vessel classes', + }, + { + id: 'miyako_strait', + name: 'Miyako Strait', + primaryChokepointId: 'taiwan_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.04, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Between Miyako Island and Okinawa; monitored by Japan Maritime Self-Defense Force', + }, + + // ── Dover Strait bypasses ───────────────────────────────────────────── + { + id: 'north_sea_scotland', + name: 'North Sea / Scotland Route', + primaryChokepointId: 'dover_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.02, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Northern route around Scotland; minor added distance for most vessel types', + }, + { + id: 'channel_tunnel', + name: 'Channel Tunnel (Rail Freight)', + primaryChokepointId: 'dover_strait', + type: 'modal_shift', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.35, + capacityConstraintTonnage: 0.5, + suitableCargoTypes: ['container'], + activationThreshold: 'full_closure', + notes: 'Rail freight via Eurotunnel; 0.5 Mt/yr capacity; containers only; requires full closure to justify modal shift', + }, + + // ── Strait of Gibraltar ─────────────────────────────────────────────── + { + id: 'gibraltar_no_bypass', + name: 'No Practical Bypass', + primaryChokepointId: 'gibraltar', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 0, + addedCostMultiplier: 1.0, + capacityConstraintTonnage: null, + suitableCargoTypes: [], + activationThreshold: 'full_closure', + notes: 'No practical bypass — all Atlantic-Med traffic transits here', + }, + + // ── Cape of Good Hope ───────────────────────────────────────────────── + { + id: 'cape_of_good_hope_is_bypass', + name: 'Cape of Good Hope (Is a Bypass Route)', + primaryChokepointId: 'cape_of_good_hope', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 0, + addedCostMultiplier: 1.0, + capacityConstraintTonnage: null, + suitableCargoTypes: [], + activationThreshold: 'full_closure', + notes: 'Cape of Good Hope IS a bypass route for Suez/Bab-el-Mandeb — no secondary bypass available', + }, + + // ── Korea Strait bypasses ───────────────────────────────────────────── + { + id: 'la_perouse_strait', + name: 'La Perouse Strait (Soya Strait)', + primaryChokepointId: 'korea_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 2, + addedCostMultiplier: 1.06, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk'], + activationThreshold: 'partial_closure', + notes: 'Seasonal — limited ice conditions Nov-Apr', + }, + { + id: 'tsugaru_strait', + name: 'Tsugaru Strait', + primaryChokepointId: 'korea_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.04, + capacityConstraintTonnage: null, + suitableCargoTypes: ['container', 'tanker', 'bulk', 'roro'], + activationThreshold: 'partial_closure', + notes: 'Between Hokkaido and Honshu; narrower but ice-free year-round', + }, + + // ── Kerch Strait bypasses ───────────────────────────────────────────── + { + id: 'black_sea_western_ports', + name: 'Black Sea Western Ports Reroute', + primaryChokepointId: 'kerch_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 3, + addedCostMultiplier: 1.2, + capacityConstraintTonnage: null, + suitableCargoTypes: ['tanker', 'bulk'], + activationThreshold: 'partial_closure', + notes: 'Reroute to Constanta/Odesa/Varna; effectively blockaded since Feb 2022', + }, + { + id: 'ukraine_rail_reroute', + name: 'Ukraine Rail Reroute', + primaryChokepointId: 'kerch_strait', + type: 'land_bridge', + waypointChokepointIds: [], + addedTransitDays: 5, + addedCostMultiplier: 1.4, + capacityConstraintTonnage: 2, + suitableCargoTypes: ['container'], + activationThreshold: 'full_closure', + notes: 'Rail through Ukraine to EU entry points; 2 Mt/yr capacity; significant geopolitical risk', + }, + + // ── Lombok Strait bypasses ──────────────────────────────────────────── + { + id: 'sunda_strait_for_lombok', + name: 'Sunda Strait', + primaryChokepointId: 'lombok_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.03, + capacityConstraintTonnage: null, + suitableCargoTypes: ['tanker', 'bulk', 'container'], + activationThreshold: 'partial_closure', + notes: 'Shallower than Lombok; suitable for most vessel classes except VLCCs', + }, + { + id: 'ombai_strait', + name: 'Ombai Strait', + primaryChokepointId: 'lombok_strait', + type: 'alternative_sea_route', + waypointChokepointIds: [], + addedTransitDays: 1, + addedCostMultiplier: 1.02, + capacityConstraintTonnage: null, + suitableCargoTypes: ['tanker', 'bulk'], + activationThreshold: 'partial_closure', + notes: 'Deep-water passage between Alor and Timor; primarily tanker and bulk', + }, +]; + +export const BYPASS_CORRIDORS_BY_CHOKEPOINT: Readonly> = + BYPASS_CORRIDORS.reduce((acc, c) => { + (acc[c.primaryChokepointId] ??= []).push(c); + return acc; + }, {} as Record); diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 322d73747..cd7539e13 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -79,6 +79,13 @@ export const CHOKEPOINT_EXPOSURE_SEED_META_KEY = 'seed-meta:supply_chain:chokepo export const COST_SHOCK_KEY = (iso2: string, chokepointId: string) => `supply-chain:cost-shock:${iso2}:${chokepointId}:v1` as const; +/** + * Per-country + per-HS2 sector dependency cache. + * NOT in bootstrap — request-varying, PRO-gated. + */ +export const SECTOR_DEPENDENCY_KEY = (iso2: string, hs2: string) => + `supply-chain:sector-dep:${iso2}:${hs2}:v1` as const; + /** * Shared chokepoint status cache key — written by get-chokepoint-status, read by bypass-options and cost-shock handlers. */ diff --git a/server/_shared/chokepoint-registry.ts b/server/_shared/chokepoint-registry.ts new file mode 100644 index 000000000..390a78819 --- /dev/null +++ b/server/_shared/chokepoint-registry.ts @@ -0,0 +1,217 @@ +/** + * Single source of truth for the 13 canonical chokepoints. + * + * All other chokepoint references in the codebase should derive from or + * validate against this registry. Key relationships: + * - `id` → canonical ID used everywhere in this repo + * - `geoId` → same as `id`; matches STRATEGIC_WATERWAYS.id in geo.ts + * - `relayName` → display name used by the AIS relay + * - `portwatchName` → name in PortWatch transit data + * - `corridorRiskName`→ name in CorridorRisk feed (null = not covered) + * - `baselineId` → EIA/IEA energy baseline ID (null = no energy model) + * - `shockModelSupported` → true for the 4 chokepoints with an energy shock model + * - `routeIds` → TRADE_ROUTES.id values that include this chokepoint + */ +export interface ChokepointRegistryEntry { + id: string; + displayName: string; + /** Same as id — matches STRATEGIC_WATERWAYS.id in geo.ts */ + geoId: string; + relayName: string; + portwatchName: string; + corridorRiskName: string | null; + /** EIA chokepoint baseline ID. Null = no EIA baseline. */ + baselineId: string | null; + /** + * True for the 4 chokepoints that have an energy shock model + * (suez, malacca_strait, hormuz_strait, bab_el_mandeb). + */ + shockModelSupported: boolean; + /** IDs of TRADE_ROUTES entries whose waypoints include this chokepoint. */ + routeIds: string[]; + lat: number; + lon: number; +} + +export const CHOKEPOINT_REGISTRY: readonly ChokepointRegistryEntry[] = [ + { + id: 'suez', + displayName: 'Suez Canal', + geoId: 'suez', + relayName: 'Suez Canal', + portwatchName: 'Suez Canal', + corridorRiskName: 'Suez', + baselineId: 'suez', + shockModelSupported: true, + routeIds: ['china-europe-suez', 'china-us-east-suez', 'gulf-europe-oil', 'qatar-europe-lng', 'singapore-med', 'india-europe'], + lat: 30.5, + lon: 32.3, + }, + { + id: 'malacca_strait', + displayName: 'Strait of Malacca', + geoId: 'malacca_strait', + relayName: 'Malacca Strait', + portwatchName: 'Malacca Strait', + corridorRiskName: 'Malacca', + baselineId: 'malacca', + shockModelSupported: true, + routeIds: ['china-europe-suez', 'china-us-east-suez', 'gulf-asia-oil', 'qatar-asia-lng', 'india-se-asia', 'china-africa', 'cpec-route'], + lat: 2.5, + lon: 101.5, + }, + { + id: 'hormuz_strait', + displayName: 'Strait of Hormuz', + geoId: 'hormuz_strait', + relayName: 'Strait of Hormuz', + portwatchName: 'Strait of Hormuz', + corridorRiskName: 'Hormuz', + baselineId: 'hormuz', + shockModelSupported: true, + routeIds: ['gulf-europe-oil', 'gulf-asia-oil', 'qatar-europe-lng', 'qatar-asia-lng', 'gulf-americas-cape'], + lat: 26.5, + lon: 56.5, + }, + { + id: 'bab_el_mandeb', + displayName: 'Bab el-Mandeb', + geoId: 'bab_el_mandeb', + relayName: 'Bab el-Mandeb Strait', + portwatchName: 'Bab el-Mandeb Strait', + corridorRiskName: 'Bab el-Mandeb', + baselineId: 'babelm', + shockModelSupported: true, + routeIds: ['china-europe-suez', 'china-us-east-suez', 'gulf-europe-oil', 'qatar-europe-lng', 'singapore-med', 'india-europe'], + lat: 12.5, + lon: 43.3, + }, + { + id: 'panama', + displayName: 'Panama Canal', + geoId: 'panama', + relayName: 'Panama Canal', + portwatchName: 'Panama Canal', + corridorRiskName: 'Panama', + baselineId: 'panama', + shockModelSupported: false, + routeIds: ['china-us-east-panama', 'panama-transit'], + lat: 9.1, + lon: -79.7, + }, + { + id: 'taiwan_strait', + displayName: 'Taiwan Strait', + geoId: 'taiwan_strait', + relayName: 'Taiwan Strait', + portwatchName: 'Taiwan Strait', + corridorRiskName: 'Taiwan', + baselineId: null, + shockModelSupported: false, + routeIds: ['china-us-west', 'intra-asia-container'], + lat: 24.0, + lon: 119.5, + }, + { + id: 'cape_of_good_hope', + displayName: 'Cape of Good Hope', + geoId: 'cape_of_good_hope', + relayName: 'Cape of Good Hope', + portwatchName: 'Cape of Good Hope', + corridorRiskName: 'Cape of Good Hope', + baselineId: null, + shockModelSupported: false, + routeIds: ['brazil-china-bulk', 'gulf-americas-cape', 'asia-europe-cape'], + lat: -34.36, + lon: 18.49, + }, + { + id: 'gibraltar', + displayName: 'Strait of Gibraltar', + geoId: 'gibraltar', + relayName: 'Gibraltar Strait', + portwatchName: 'Gibraltar Strait', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: ['gulf-europe-oil', 'singapore-med', 'india-europe', 'asia-europe-cape'], + lat: 35.9, + lon: -5.6, + }, + { + id: 'bosphorus', + displayName: 'Bosporus Strait', + geoId: 'bosphorus', + relayName: 'Bosporus Strait', + portwatchName: 'Bosporus Strait', + corridorRiskName: null, + baselineId: 'turkish', + shockModelSupported: false, + routeIds: ['russia-med-oil'], + lat: 41.1, + lon: 29.0, + }, + { + id: 'korea_strait', + displayName: 'Korea Strait', + geoId: 'korea_strait', + relayName: 'Korea Strait', + portwatchName: 'Korea Strait', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: [], + lat: 34.0, + lon: 129.0, + }, + { + id: 'dover_strait', + displayName: 'Dover Strait', + geoId: 'dover_strait', + relayName: 'Dover Strait', + portwatchName: 'Dover Strait', + corridorRiskName: null, + baselineId: 'danish', + shockModelSupported: false, + routeIds: [], + lat: 51.0, + lon: 1.5, + }, + { + id: 'kerch_strait', + displayName: 'Kerch Strait', + geoId: 'kerch_strait', + relayName: 'Kerch Strait', + portwatchName: 'Kerch Strait', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: [], + lat: 45.3, + lon: 36.6, + }, + { + id: 'lombok_strait', + displayName: 'Lombok Strait', + geoId: 'lombok_strait', + relayName: 'Lombok Strait', + portwatchName: 'Lombok Strait', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: [], + lat: -8.5, + lon: 115.7, + }, +]; + +/** Set of canonical IDs for fast membership checks. */ +export const CANONICAL_CHOKEPOINT_IDS = new Set(CHOKEPOINT_REGISTRY.map(c => c.id)); + +/** Lookup by canonical ID. */ +export function getChokepoint(id: string): ChokepointRegistryEntry | undefined { + return CHOKEPOINT_REGISTRY.find(c => c.id === id); +} + +/** Chokepoints that have an energy shock model (oil + LNG). */ +export const SHOCK_MODEL_CHOKEPOINTS = CHOKEPOINT_REGISTRY.filter(c => c.shockModelSupported); diff --git a/server/gateway.ts b/server/gateway.ts index 1850f6163..639251de8 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -211,6 +211,7 @@ const RPC_CACHE_TIER: Record = { '/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/health/v1/list-disease-outbreaks': 'slow', '/api/health/v1/list-air-quality-alerts': 'fast', '/api/intelligence/v1/get-social-velocity': 'fast', diff --git a/server/worldmonitor/supply-chain/v1/get-sector-dependency.ts b/server/worldmonitor/supply-chain/v1/get-sector-dependency.ts new file mode 100644 index 000000000..5b97be6d4 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-sector-dependency.ts @@ -0,0 +1,186 @@ +import type { + ServerContext, + GetSectorDependencyRequest, + GetSectorDependencyResponse, + DependencyFlag, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { cachedFetchJson, getCachedJson } from '../../../_shared/redis'; +import { SECTOR_DEPENDENCY_KEY } from '../../../_shared/cache-keys'; +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; +import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../_shared/bypass-corridors'; +import { ISO2_TO_COMTRADE } from '../../intelligence/v1/_comtrade-reporters'; +import COUNTRY_PORT_CLUSTERS from '../../../../scripts/shared/country-port-clusters.json'; + +const CACHE_TTL = 86400; // 24 hours + +const HS2_LABELS: Record = { + '1': 'Live Animals', '2': 'Meat', '3': 'Fish & Seafood', '4': 'Dairy', + '6': 'Plants & Flowers', '7': 'Vegetables', '8': 'Fruit & Nuts', + '10': 'Cereals', '11': 'Milling Products', '12': 'Oilseeds', '15': 'Animal & Vegetable Fats', + '16': 'Meat Preparations', '17': 'Sugar', '18': 'Cocoa', '19': 'Food Preparations', + '22': 'Beverages & Spirits', '23': 'Residues & Animal Feed', '24': 'Tobacco', + '25': 'Salt & Cement', '26': 'Ores, Slag & Ash', '27': 'Mineral Fuels & Energy', + '28': 'Inorganic Chemicals', '29': 'Organic Chemicals', '30': 'Pharmaceuticals', + '31': 'Fertilizers', '38': 'Chemical Products', '39': 'Plastics', + '40': 'Rubber', '44': 'Wood', '47': 'Pulp & Paper', '48': 'Paper & Paperboard', + '52': 'Cotton', '61': 'Clothing (Knitted)', '62': 'Clothing (Woven)', + '71': 'Precious Metals & Gems', '72': 'Iron & Steel', '73': 'Iron & Steel Articles', + '74': 'Copper', '76': 'Aluminium', '79': 'Zinc', '80': 'Tin', + '84': 'Machinery & Mechanical Appliances', '85': 'Electrical & Electronic Equipment', + '86': 'Railway', '87': 'Vehicles', '88': 'Aircraft', '89': 'Ships & Boats', + '90': 'Optical & Medical Instruments', '93': 'Arms & Ammunition', +}; + +interface PortClusterEntry { nearestRouteIds: string[]; coastSide: string; } + +interface ComtradeFlowRecord { + reporterCode: string; + partnerCode: string; + cmdCode: string; + tradeValueUsd: number; + year: number; +} + +interface ComtradeFlowsResult { + flows?: ComtradeFlowRecord[]; + fetchedAt?: string; +} + +function computeExposures(nearestRouteIds: string[], hs2: string) { + // Landlocked or unmapped countries have no routes; return empty so callers + // receive primaryChokepointId = '' and primaryChokepointExposure = 0 rather than + // an arbitrary registry-first entry with score 0. + if (nearestRouteIds.length === 0) return []; + const isEnergy = hs2 === '27'; + const routeSet = new Set(nearestRouteIds); + return CHOKEPOINT_REGISTRY.map(cp => { + const overlap = cp.routeIds.filter(r => routeSet.has(r)).length; + const maxRoutes = Math.max(cp.routeIds.length, 1); + let score = (overlap / maxRoutes) * 100; + if (isEnergy && cp.shockModelSupported) score = Math.min(score * 1.5, 100); + return { chokepointId: cp.id, exposureScore: Math.round(score * 10) / 10 }; + }).sort((a, b) => b.exposureScore - a.exposureScore); +} + +async function getTopExporterShare(iso2: string, hs2: string): Promise<{ exporterIso2: string; share: number }> { + const numericCode = ISO2_TO_COMTRADE[iso2]; + if (!numericCode) return { exporterIso2: '', share: 0 }; + + const key = `comtrade:flows:${numericCode}:${hs2.padStart(4, '0').slice(0, 4)}`; + const result = await getCachedJson(key, true).catch(() => null); + if (!result) return { exporterIso2: '', share: 0 }; + + const raw = result as ComtradeFlowsResult; + const flows: ComtradeFlowRecord[] = Array.isArray(result) + ? (result as ComtradeFlowRecord[]) + : (raw.flows ?? []); + + if (flows.length === 0) return { exporterIso2: '', share: 0 }; + + const totals = new Map(); + let grandTotal = 0; + for (const f of flows) { + if (!f.partnerCode || f.partnerCode === '0' || f.partnerCode === '899') continue; + const prev = totals.get(f.partnerCode) ?? 0; + totals.set(f.partnerCode, prev + (f.tradeValueUsd ?? 0)); + grandTotal += f.tradeValueUsd ?? 0; + } + if (grandTotal === 0) return { exporterIso2: '', share: 0 }; + + let topCode = ''; + let topValue = 0; + for (const [code, val] of totals) { + if (val > topValue) { topValue = val; topCode = code; } + } + + const share = topValue / grandTotal; + // Reverse-lookup numeric code to ISO2 + const exporterIso2 = Object.entries(ISO2_TO_COMTRADE).find(([, v]) => v === topCode)?.[0] ?? ''; + return { exporterIso2, share }; +} + +export async function getSectorDependency( + ctx: ServerContext, + req: GetSectorDependencyRequest, +): Promise { + const isPro = await isCallerPremium(ctx.request); + const empty: GetSectorDependencyResponse = { + iso2: req.iso2, + hs2: req.hs2 || '27', + hs2Label: HS2_LABELS[req.hs2 || '27'] ?? `HS ${req.hs2}`, + flags: [], + primaryExporterIso2: '', + primaryExporterShare: 0, + primaryChokepointId: '', + primaryChokepointExposure: 0, + hasViableBypass: false, + fetchedAt: new Date().toISOString(), + }; + if (!isPro) return empty; + + const iso2 = req.iso2?.trim().toUpperCase(); + const hs2 = req.hs2?.trim().replace(/\D/g, '') || '27'; + + if (!/^[A-Z]{2}$/.test(iso2 ?? '') || !/^\d{1,2}$/.test(hs2)) { + return { ...empty, iso2: iso2 ?? '', hs2 }; + } + + const cacheKey = SECTOR_DEPENDENCY_KEY(iso2, hs2); + + try { + const result = await cachedFetchJson( + cacheKey, + CACHE_TTL, + async () => { + const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record; + const cluster = clusters[iso2]; + const nearestRouteIds = cluster?.nearestRouteIds ?? []; + + const exposures = computeExposures(nearestRouteIds, hs2); + const primary = exposures[0]; + + const primaryChokepointId = primary?.chokepointId ?? ''; + const primaryChokepointExposure = primary?.exposureScore ?? 0; + + const bypassCorridors = BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepointId] ?? []; + const hasViableBypass = bypassCorridors.some(c => c.suitableCargoTypes.length > 0); + + const { exporterIso2, share: primaryExporterShare } = await getTopExporterShare(iso2, hs2); + + const isSingleSource = primaryExporterShare > 0.8; + const isSingleCorridor = primaryChokepointExposure > 80 && !hasViableBypass; + const isDiversifiable = hasViableBypass && !isSingleSource; + + const flags: DependencyFlag[] = []; + if (isSingleSource && isSingleCorridor) { + flags.push('DEPENDENCY_FLAG_COMPOUND_RISK'); + } else if (isSingleSource) { + flags.push('DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL'); + } else if (isSingleCorridor) { + flags.push('DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL'); + } else if (isDiversifiable) { + flags.push('DEPENDENCY_FLAG_DIVERSIFIABLE'); + } + + return { + iso2, + hs2, + hs2Label: HS2_LABELS[hs2] ?? `HS ${hs2}`, + flags, + primaryExporterIso2: exporterIso2, + primaryExporterShare: Math.round(primaryExporterShare * 1000) / 1000, + primaryChokepointId, + primaryChokepointExposure, + hasViableBypass, + fetchedAt: new Date().toISOString(), + }; + }, + ); + + return result ?? { ...empty, iso2, hs2 }; + } catch { + return { ...empty, iso2, hs2 }; + } +} diff --git a/server/worldmonitor/supply-chain/v1/handler.ts b/server/worldmonitor/supply-chain/v1/handler.ts index f6915dbbb..abc1bbb62 100644 --- a/server/worldmonitor/supply-chain/v1/handler.ts +++ b/server/worldmonitor/supply-chain/v1/handler.ts @@ -7,6 +7,7 @@ import { getShippingStress } from './get-shipping-stress'; import { getCountryChokepointIndex } from './get-country-chokepoint-index'; import { getBypassOptions } from './get-bypass-options'; import { getCountryCostShock } from './get-country-cost-shock'; +import { getSectorDependency } from './get-sector-dependency'; export const supplyChainHandler: SupplyChainServiceHandler = { getShippingRates, @@ -16,4 +17,5 @@ export const supplyChainHandler: SupplyChainServiceHandler = { getCountryChokepointIndex, getBypassOptions, getCountryCostShock, + getSectorDependency, }; diff --git a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts index bc4c8f562..37c95236f 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -218,6 +218,26 @@ export interface GetCountryCostShockResponse { fetchedAt: string; } +export interface GetSectorDependencyRequest { + iso2: string; + hs2: string; +} + +export interface GetSectorDependencyResponse { + iso2: string; + hs2: string; + hs2Label: string; + flags: DependencyFlag[]; + primaryExporterIso2: string; + primaryExporterShare: number; + primaryChokepointId: string; + primaryChokepointExposure: number; + hasViableBypass: boolean; + fetchedAt: string; +} + +export type DependencyFlag = "DEPENDENCY_FLAG_UNSPECIFIED" | "DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL" | "DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL" | "DEPENDENCY_FLAG_COMPOUND_RISK" | "DEPENDENCY_FLAG_DIVERSIFIABLE"; + export type WarRiskTier = "WAR_RISK_TIER_UNSPECIFIED" | "WAR_RISK_TIER_NORMAL" | "WAR_RISK_TIER_ELEVATED" | "WAR_RISK_TIER_HIGH" | "WAR_RISK_TIER_CRITICAL" | "WAR_RISK_TIER_WAR_ZONE"; export interface FieldViolation { @@ -440,6 +460,32 @@ export class SupplyChainServiceClient { return await resp.json() as GetCountryCostShockResponse; } + async getSectorDependency(req: GetSectorDependencyRequest, options?: SupplyChainServiceCallOptions): Promise { + let path = "/api/supply-chain/v1/get-sector-dependency"; + const params = new URLSearchParams(); + if (req.iso2 != null && req.iso2 !== "") params.set("iso2", String(req.iso2)); + if (req.hs2 != null && req.hs2 !== "") params.set("hs2", String(req.hs2)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as GetSectorDependencyResponse; + } + private async handleError(resp: Response): Promise { const body = await resp.text(); if (resp.status === 400) { diff --git a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts index 1c2c473bf..e1476a519 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -218,6 +218,26 @@ export interface GetCountryCostShockResponse { fetchedAt: string; } +export interface GetSectorDependencyRequest { + iso2: string; + hs2: string; +} + +export interface GetSectorDependencyResponse { + iso2: string; + hs2: string; + hs2Label: string; + flags: DependencyFlag[]; + primaryExporterIso2: string; + primaryExporterShare: number; + primaryChokepointId: string; + primaryChokepointExposure: number; + hasViableBypass: boolean; + fetchedAt: string; +} + +export type DependencyFlag = "DEPENDENCY_FLAG_UNSPECIFIED" | "DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL" | "DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL" | "DEPENDENCY_FLAG_COMPOUND_RISK" | "DEPENDENCY_FLAG_DIVERSIFIABLE"; + export type WarRiskTier = "WAR_RISK_TIER_UNSPECIFIED" | "WAR_RISK_TIER_NORMAL" | "WAR_RISK_TIER_ELEVATED" | "WAR_RISK_TIER_HIGH" | "WAR_RISK_TIER_CRITICAL" | "WAR_RISK_TIER_WAR_ZONE"; export interface FieldViolation { @@ -272,6 +292,7 @@ export interface SupplyChainServiceHandler { getCountryChokepointIndex(ctx: ServerContext, req: GetCountryChokepointIndexRequest): Promise; getBypassOptions(ctx: ServerContext, req: GetBypassOptionsRequest): Promise; getCountryCostShock(ctx: ServerContext, req: GetCountryCostShockRequest): Promise; + getSectorDependency(ctx: ServerContext, req: GetSectorDependencyRequest): Promise; } export function createSupplyChainServiceRoutes( @@ -573,6 +594,54 @@ export function createSupplyChainServiceRoutes( } }, }, + { + method: "GET", + path: "/api/supply-chain/v1/get-sector-dependency", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetSectorDependencyRequest = { + iso2: params.get("iso2") ?? "", + hs2: params.get("hs2") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getSectorDependency", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getSectorDependency(ctx, body); + return new Response(JSON.stringify(result as GetSectorDependencyResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, ]; }