diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index 20b90575d..b2b1d19f1 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"},"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 +{"components":{"schemas":{"BypassCorridorOption":{"description":"BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI.\n Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes\n directly without any client-side geometry lookup.","properties":{"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"fromPort":{"$ref":"#/components/schemas/GeoPoint"},"id":{"type":"string"},"name":{"type":"string"},"status":{"description":"Status of a bypass corridor for UI labeling. \"active\" means usable today;\n \"proposed\" means documented but not yet built/operational; \"unavailable\"\n means blockaded or otherwise blocked from use.","enum":["CORRIDOR_STATUS_UNSPECIFIED","CORRIDOR_STATUS_ACTIVE","CORRIDOR_STATUS_PROPOSED","CORRIDOR_STATUS_UNAVAILABLE"],"type":"string"},"toPort":{"$ref":"#/components/schemas/GeoPoint"},"type":{"type":"string"},"warRiskTier":{"type":"string"}},"type":"object"},"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"},"ChokepointExposureSummary":{"properties":{"chokepointId":{"type":"string"},"chokepointName":{"type":"string"},"exposurePct":{"format":"int32","type":"integer"}},"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"},"GeoPoint":{"description":"GeoPoint is a [longitude, latitude] pair.","properties":{"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"}},"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"},"GetRouteExplorerLaneRequest":{"properties":{"cargoType":{"description":"One of: container, tanker, bulk, roro","type":"string"},"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"description":"HS2 chapter code, e.g. \"27\", \"85\"","type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2","cargoType"],"type":"object"},"GetRouteExplorerLaneResponse":{"properties":{"bypassOptions":{"items":{"$ref":"#/components/schemas/BypassCorridorOption"},"type":"array"},"cargoType":{"type":"string"},"chokepointExposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureSummary"},"type":"array"},"disruptionScore":{"format":"double","type":"number"},"estFreightUsdPerTeuRange":{"$ref":"#/components/schemas/NumberRange"},"estTransitDaysRange":{"$ref":"#/components/schemas/NumberRange"},"fetchedAt":{"type":"string"},"fromIso2":{"type":"string"},"hs2":{"type":"string"},"noModeledLane":{"description":"True when the wrapper fell back to the origin's first route (no shared route\n between origin and destination clusters). Signals \"no modeled lane\" to the UI.","type":"boolean"},"primaryRouteGeometry":{"items":{"$ref":"#/components/schemas/GeoPoint"},"type":"array"},"primaryRouteId":{"description":"Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane.","type":"string"},"toIso2":{"type":"string"},"warRiskTier":{"type":"string"}},"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"},"NumberRange":{"description":"Inclusive integer range for transit days / freight USD estimates.","properties":{"max":{"format":"int32","type":"integer"},"min":{"format":"int32","type":"integer"}},"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-route-explorer-lane":{"get":{"description":"GetRouteExplorerLane returns the primary maritime route, chokepoint exposures,\n bypass options with geometry, war risk, and static transit/freight estimates for\n a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor\n endpoint's compute with browser-callable auth and adds fields needed by the\n Route Explorer UI.","operationId":"GetRouteExplorerLane","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter code, e.g. \"27\", \"85\"","in":"query","name":"hs2","required":false,"schema":{"type":"string"}},{"description":"One of: container, tanker, bulk, roro","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteExplorerLaneResponse"}}},"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":"GetRouteExplorerLane","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 c6de683c3..ac19995be 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -269,6 +269,60 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/supply-chain/v1/get-route-explorer-lane: + get: + tags: + - SupplyChainService + summary: GetRouteExplorerLane + description: |- + GetRouteExplorerLane returns the primary maritime route, chokepoint exposures, + bypass options with geometry, war risk, and static transit/freight estimates for + a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor + endpoint's compute with browser-callable auth and adds fields needed by the + Route Explorer UI. + operationId: GetRouteExplorerLane + parameters: + - name: fromIso2 + in: query + required: false + schema: + type: string + - name: toIso2 + in: query + required: false + schema: + type: string + - name: hs2 + in: query + description: HS2 chapter code, e.g. "27", "85" + required: false + schema: + type: string + - name: cargoType + in: query + description: 'One of: container, tanker, bulk, roro' + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetRouteExplorerLaneResponse' + "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: @@ -883,3 +937,131 @@ components: description: Whether at least one viable bypass corridor exists for the primary chokepoint. fetchedAt: type: string + GetRouteExplorerLaneRequest: + type: object + properties: + fromIso2: + type: string + pattern: ^[A-Z]{2}$ + toIso2: + type: string + pattern: ^[A-Z]{2}$ + hs2: + type: string + description: HS2 chapter code, e.g. "27", "85" + cargoType: + type: string + description: 'One of: container, tanker, bulk, roro' + required: + - fromIso2 + - toIso2 + - hs2 + - cargoType + GetRouteExplorerLaneResponse: + type: object + properties: + fromIso2: + type: string + toIso2: + type: string + hs2: + type: string + cargoType: + type: string + primaryRouteId: + type: string + description: Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane. + primaryRouteGeometry: + type: array + items: + $ref: '#/components/schemas/GeoPoint' + chokepointExposures: + type: array + items: + $ref: '#/components/schemas/ChokepointExposureSummary' + bypassOptions: + type: array + items: + $ref: '#/components/schemas/BypassCorridorOption' + warRiskTier: + type: string + disruptionScore: + type: number + format: double + estTransitDaysRange: + $ref: '#/components/schemas/NumberRange' + estFreightUsdPerTeuRange: + $ref: '#/components/schemas/NumberRange' + noModeledLane: + type: boolean + description: |- + True when the wrapper fell back to the origin's first route (no shared route + between origin and destination clusters). Signals "no modeled lane" to the UI. + fetchedAt: + type: string + GeoPoint: + type: object + properties: + lon: + type: number + format: double + lat: + type: number + format: double + description: GeoPoint is a [longitude, latitude] pair. + ChokepointExposureSummary: + type: object + properties: + chokepointId: + type: string + chokepointName: + type: string + exposurePct: + type: integer + format: int32 + BypassCorridorOption: + type: object + properties: + id: + type: string + name: + type: string + type: + type: string + addedTransitDays: + type: integer + format: int32 + addedCostMultiplier: + type: number + format: double + warRiskTier: + type: string + status: + type: string + enum: + - CORRIDOR_STATUS_UNSPECIFIED + - CORRIDOR_STATUS_ACTIVE + - CORRIDOR_STATUS_PROPOSED + - CORRIDOR_STATUS_UNAVAILABLE + description: |- + Status of a bypass corridor for UI labeling. "active" means usable today; + "proposed" means documented but not yet built/operational; "unavailable" + means blockaded or otherwise blocked from use. + fromPort: + $ref: '#/components/schemas/GeoPoint' + toPort: + $ref: '#/components/schemas/GeoPoint' + description: |- + BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI. + Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes + directly without any client-side geometry lookup. + NumberRange: + type: object + properties: + min: + type: integer + format: int32 + max: + type: integer + format: int32 + description: Inclusive integer range for transit days / freight USD estimates. diff --git a/proto/worldmonitor/supply_chain/v1/get_route_explorer_lane.proto b/proto/worldmonitor/supply_chain/v1/get_route_explorer_lane.proto new file mode 100644 index 000000000..8fe467854 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_route_explorer_lane.proto @@ -0,0 +1,99 @@ +syntax = "proto3"; +package worldmonitor.supply_chain.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// Status of a bypass corridor for UI labeling. "active" means usable today; +// "proposed" means documented but not yet built/operational; "unavailable" +// means blockaded or otherwise blocked from use. +enum CorridorStatus { + CORRIDOR_STATUS_UNSPECIFIED = 0; + CORRIDOR_STATUS_ACTIVE = 1; + CORRIDOR_STATUS_PROPOSED = 2; + CORRIDOR_STATUS_UNAVAILABLE = 3; +} + +// GeoPoint is a [longitude, latitude] pair. +message GeoPoint { + double lon = 1; + double lat = 2; +} + +// BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI. +// Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes +// directly without any client-side geometry lookup. +message BypassCorridorOption { + string id = 1; + string name = 2; + string type = 3; + int32 added_transit_days = 4; + double added_cost_multiplier = 5; + string war_risk_tier = 6; + CorridorStatus status = 7; + GeoPoint from_port = 8; + GeoPoint to_port = 9; +} + +message ChokepointExposureSummary { + string chokepoint_id = 1; + string chokepoint_name = 2; + int32 exposure_pct = 3; +} + +// Inclusive integer range for transit days / freight USD estimates. +message NumberRange { + int32 min = 1; + int32 max = 2; +} + +message GetRouteExplorerLaneRequest { + string from_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: "fromIso2"} + ]; + string to_iso2 = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$", + (sebuf.http.query) = {name: "toIso2"} + ]; + // HS2 chapter code, e.g. "27", "85" + string hs2 = 3 [ + (buf.validate.field).required = true, + (sebuf.http.query) = {name: "hs2"} + ]; + // One of: container, tanker, bulk, roro + string cargo_type = 4 [ + (buf.validate.field).required = true, + (sebuf.http.query) = {name: "cargoType"} + ]; +} + +message GetRouteExplorerLaneResponse { + string from_iso2 = 1; + string to_iso2 = 2; + string hs2 = 3; + string cargo_type = 4; + // Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane. + string primary_route_id = 5; + // Polyline waypoints for the primary route (lon/lat), used for map rendering. + repeated GeoPoint primary_route_geometry = 6; + // Chokepoints on the primary route, ranked by exposure descending. + repeated ChokepointExposureSummary chokepoint_exposures = 7; + // Ranked bypass options with geometry endpoints for map rendering. + repeated BypassCorridorOption bypass_options = 8; + string war_risk_tier = 9; + double disruption_score = 10; + // Static transit estimate from a hand-curated table keyed by primary_route_id. + NumberRange est_transit_days_range = 11; + // Static freight estimate (USD per TEU or equivalent) from a hand-curated table + // keyed by cargo_type. Not a live rate quote. + NumberRange est_freight_usd_per_teu_range = 12; + // True when the wrapper fell back to the origin's first route (no shared route + // between origin and destination clusters). Signals "no modeled lane" to the UI. + bool no_modeled_lane = 13; + string fetched_at = 14; +} diff --git a/proto/worldmonitor/supply_chain/v1/service.proto b/proto/worldmonitor/supply_chain/v1/service.proto index b9736eb25..b21cd2b45 100644 --- a/proto/worldmonitor/supply_chain/v1/service.proto +++ b/proto/worldmonitor/supply_chain/v1/service.proto @@ -11,6 +11,7 @@ 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"; +import "worldmonitor/supply_chain/v1/get_route_explorer_lane.proto"; service SupplyChainService { option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"}; @@ -51,4 +52,13 @@ service SupplyChainService { rpc GetSectorDependency(GetSectorDependencyRequest) returns (GetSectorDependencyResponse) { option (sebuf.http.config) = {path: "/get-sector-dependency", method: HTTP_METHOD_GET}; } + + // GetRouteExplorerLane returns the primary maritime route, chokepoint exposures, + // bypass options with geometry, war risk, and static transit/freight estimates for + // a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor + // endpoint's compute with browser-callable auth and adds fields needed by the + // Route Explorer UI. + rpc GetRouteExplorerLane(GetRouteExplorerLaneRequest) returns (GetRouteExplorerLaneResponse) { + option (sebuf.http.config) = {path: "/get-route-explorer-lane", method: HTTP_METHOD_GET}; + } } diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index ed5254805..c9e4c8b43 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -86,6 +86,17 @@ export const COST_SHOCK_KEY = (iso2: string, chokepointId: string) => export const SECTOR_DEPENDENCY_KEY = (iso2: string, hs2: string) => `supply-chain:sector-dep:${iso2}:${hs2}:v1` as const; +/** + * Route Explorer lane cache — per (fromIso2, toIso2, hs2, cargoType). + * NOT in bootstrap — request-varying, PRO-gated. + */ +export const ROUTE_EXPLORER_LANE_KEY = ( + fromIso2: string, + toIso2: string, + hs2: string, + cargoType: string, +) => `supply-chain:route-explorer-lane:${fromIso2}:${toIso2}:${hs2}:${cargoType}: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/gateway.ts b/server/gateway.ts index 1671b7fcb..fdd285fe9 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -214,6 +214,7 @@ const RPC_CACHE_TIER: Record = { '/api/supply-chain/v1/get-bypass-options': 'slow-browser', '/api/supply-chain/v1/get-country-cost-shock': 'slow-browser', '/api/supply-chain/v1/get-sector-dependency': 'slow-browser', + '/api/supply-chain/v1/get-route-explorer-lane': 'slow-browser', '/api/health/v1/list-disease-outbreaks': 'slow', '/api/health/v1/list-air-quality-alerts': 'fast', '/api/intelligence/v1/get-social-velocity': 'fast', diff --git a/server/worldmonitor/supply-chain/v1/_route-explorer-static-tables.ts b/server/worldmonitor/supply-chain/v1/_route-explorer-static-tables.ts new file mode 100644 index 000000000..72c4c74d9 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/_route-explorer-static-tables.ts @@ -0,0 +1,217 @@ +/** + * Static lookup tables for the Route Explorer wrapper RPC. + * + * These are hand-curated estimates, NOT live rate quotes. They exist because: + * - `route-intelligence` does not return transit days or freight estimates + * - `BYPASS_CORRIDORS_BY_CHOKEPOINT` has no geometry fields; the client-side + * `MapContainer.setBypassRoutes` API wants coordinate pairs, not IDs + * + * Every number here should be treated as a rough industry average, not + * authoritative. If these ever need to move, replace with a live data source + * (Baltic Exchange, Freightos, etc.) rather than extending these tables. + */ + +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; + +// ─── Transit days per TRADE_ROUTES ID ──────────────────────────────────────── + +/** + * Minimum and maximum transit-day estimates per trade route, keyed by the + * `id` field from `src/config/trade-routes.ts`. Ranges span different vessel + * classes and seasonal routing choices. + */ +export const TRANSIT_DAYS_BY_ROUTE_ID: Record = { + 'china-europe-suez': [28, 35], + 'china-us-west': [14, 18], + 'china-us-east-suez': [30, 38], + 'china-us-east-panama': [24, 30], + 'gulf-europe-oil': [18, 25], + 'gulf-asia-oil': [16, 22], + 'qatar-europe-lng': [18, 24], + 'qatar-asia-lng': [12, 18], + 'us-europe-lng': [10, 14], + 'russia-med-oil': [8, 14], + 'intra-asia-container': [3, 10], + 'singapore-med': [16, 22], + 'brazil-china-bulk': [35, 45], + 'gulf-americas-cape': [30, 42], + 'asia-europe-cape': [40, 52], + 'india-europe': [18, 26], + 'india-se-asia': [6, 12], + 'china-africa': [22, 32], + 'cpec-route': [10, 16], + 'panama-transit': [1, 2], + 'transatlantic': [8, 14], +}; + +/** + * Fallback range when a `primaryRouteId` is not present in the lookup above. + * Chosen to look obviously "estimated" so UI reviewers notice if the table + * drifts out of sync with `TRADE_ROUTES`. + */ +export const TRANSIT_DAYS_FALLBACK: readonly [number, number] = [14, 28]; + +// ─── Freight estimate per cargo type ───────────────────────────────────────── + +/** + * Very rough freight cost estimate per cargo type. For containers this is USD + * per TEU; for tankers it's USD per ton; for bulk and roro it's USD per ton + * or per unit. The units are not homogeneous — the UI labels them as "est. + * freight range" without claiming a specific unit, and users are expected to + * treat it as an order-of-magnitude indicator only. + */ +export const FREIGHT_USD_BY_CARGO_TYPE: Record = { + container: [1800, 3200], + tanker: [25, 65], + bulk: [12, 30], + roro: [900, 1800], +}; + +export const FREIGHT_USD_FALLBACK: readonly [number, number] = [1800, 3200]; + +// ─── Bypass corridor geometry ──────────────────────────────────────────────── + +/** + * Coordinate-pair endpoints for every bypass corridor ID in + * `BYPASS_CORRIDORS_BY_CHOKEPOINT`. The client feeds these directly to + * `MapContainer.setBypassRoutes([{fromPort, toPort}])`, which draws an arc + * between the two points. + * + * These are *representative* endpoints, not precise port coordinates. Sea + * bypass corridors generally use the source chokepoint (from the + * `CHOKEPOINT_REGISTRY`) as `fromPort` and a notional "exit" point on the + * other side of the alternative route as `toPort`. Land-bridge corridors use + * hand-curated rail/road endpoints based on the corridor's `notes` field. + */ +export const BYPASS_CORRIDOR_GEOMETRY_BY_ID: Record< + string, + { fromPort: readonly [number, number]; toPort: readonly [number, number] } +> = { + // ── Sea alternatives (use CHOKEPOINT_REGISTRY for endpoints) ─────────── + suez_cape_of_good_hope: { + fromPort: [32.3, 30.5], // Suez + toPort: [18.49, -34.36], // Cape of Good Hope + }, + sumed_pipeline: { + fromPort: [32.58, 29.95], // Ain Sukhna terminal, Gulf of Suez + toPort: [28.88, 31.33], // Sidi Kerir terminal, Mediterranean + }, + hormuz_cape_of_good_hope: { + fromPort: [56.5, 26.5], // Hormuz Strait + toPort: [18.49, -34.36], // Cape of Good Hope + }, + btc_pipeline: { + fromPort: [49.85, 40.4], // Baku + toPort: [35.24, 36.87], // Ceyhan, Turkey + }, + lombok_strait_bypass: { + fromPort: [101.5, 2.5], // Malacca Strait + toPort: [115.7, -8.5], // Lombok Strait + }, + sunda_strait: { + fromPort: [101.5, 2.5], // Malacca Strait + toPort: [105.8, -6.0], // Sunda Strait + }, + kra_canal_future: { + fromPort: [101.5, 2.5], // Malacca Strait + toPort: [99.3, 10.0], // Kra Isthmus (notional) + }, + bab_el_mandeb_cape_of_good_hope: { + fromPort: [43.3, 12.5], // Bab el-Mandeb + toPort: [18.49, -34.36], // Cape of Good Hope + }, + btc_pipeline_black_sea: { + fromPort: [49.85, 40.4], // Baku + toPort: [41.65, 41.65], // Batumi + }, + panama_cape_horn: { + fromPort: [-79.7, 9.1], // Panama + toPort: [-67.3, -55.98], // Cape Horn + }, + bashi_channel: { + fromPort: [119.5, 24.0], // Taiwan Strait + toPort: [121.5, 21.9], // Bashi Channel + }, + miyako_strait: { + fromPort: [129.0, 34.0], // Korea Strait + toPort: [125.3, 24.85], // Miyako Strait + }, + north_sea_scotland: { + fromPort: [1.5, 51.0], // Dover Strait + toPort: [-4.0, 58.5], // North-of-Scotland route + }, + channel_tunnel: { + fromPort: [1.5, 51.0], // Dover Strait + toPort: [1.85, 50.92], // Eurotunnel Coquelles + }, + gibraltar_no_bypass: { + fromPort: [-5.6, 35.9], // Gibraltar (degenerate "no bypass" placeholder) + toPort: [-5.6, 35.9], + }, + cape_of_good_hope_is_bypass: { + fromPort: [18.49, -34.36], // Cape of Good Hope + toPort: [18.49, -34.36], + }, + la_perouse_strait: { + fromPort: [129.0, 34.0], // Korea Strait + toPort: [142.0, 45.7], // La Perouse Strait + }, + tsugaru_strait: { + fromPort: [129.0, 34.0], // Korea Strait + toPort: [140.7, 41.5], // Tsugaru Strait + }, + black_sea_western_ports: { + fromPort: [36.6, 45.3], // Kerch Strait + toPort: [28.65, 44.18], // Constanta + }, + sunda_strait_for_lombok: { + fromPort: [115.7, -8.5], // Lombok Strait + toPort: [105.8, -6.0], // Sunda Strait + }, + ombai_strait: { + fromPort: [115.7, -8.5], // Lombok Strait + toPort: [124.5, -8.4], // Ombai Strait + }, + + // ── Land-bridge corridors (hand-curated rail/road endpoints) ────────── + aqaba_land_bridge: { + fromPort: [56.5, 26.5], // Hormuz Strait (origin side) + toPort: [35.0, 29.53], // Aqaba, Jordan + }, + djibouti_rail: { + fromPort: [43.15, 11.6], // Djibouti port + toPort: [38.74, 9.03], // Addis Ababa + }, + baku_tbilisi_batumi_rail: { + fromPort: [49.85, 40.4], // Baku + toPort: [41.65, 41.65], // Batumi + }, + us_rail_landbridge: { + fromPort: [-118.25, 33.74], // Port of Los Angeles + toPort: [-74.15, 40.67], // Port of New York/New Jersey + }, + ukraine_rail_reroute: { + fromPort: [30.74, 46.48], // Odesa + toPort: [21.0, 52.23], // Warsaw (notional EU entry) + }, +}; + +/** + * Deterministic fallback when a corridor ID has no explicit geometry entry. + * Uses the chokepoint registry coordinate for both endpoints, which renders + * as a degenerate zero-length arc — intentionally obvious to reviewers. + */ +export function getCorridorGeometryOrFallback( + corridorId: string, + primaryChokepointId: string, +): { fromPort: readonly [number, number]; toPort: readonly [number, number] } { + const explicit = BYPASS_CORRIDOR_GEOMETRY_BY_ID[corridorId]; + if (explicit) return explicit; + const cp = CHOKEPOINT_REGISTRY.find((c) => c.id === primaryChokepointId); + if (cp) { + const pt: readonly [number, number] = [cp.lon, cp.lat]; + return { fromPort: pt, toPort: pt }; + } + const zero: readonly [number, number] = [0, 0]; + return { fromPort: zero, toPort: zero }; +} diff --git a/server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts b/server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts new file mode 100644 index 000000000..42a633280 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts @@ -0,0 +1,327 @@ +/** + * GET /api/supply-chain/v1/get-route-explorer-lane + * + * Internal wrapper around the vendor-only `route-intelligence` compute. Adds: + * - Browser-callable PRO gating via `premium-paths.ts` (no forceKey API-key gate) + * - `primaryRouteGeometry` polyline for map rendering + * - `fromPort` / `toPort` on every bypass option (so the client can feed + * `MapContainer.setBypassRoutes` directly without its own geometry lookup) + * - `status: 'active' | 'proposed' | 'unavailable'` per corridor, derived + * from the `notes` field to honestly label `kra_canal_future` and + * `black_sea_western_ports` + * - Static `estTransitDaysRange` and `estFreightUsdPerTeuRange` from + * hand-curated tables + * - `noModeledLane: true` when we fell back to the origin's first route + * because origin and destination clusters share no routes + * + * This handler is called through the supply-chain service dispatcher, NOT as + * an edge function — so it receives a `ServerContext` and a typed request. + */ + +import type { + ServerContext, + GetRouteExplorerLaneRequest, + GetRouteExplorerLaneResponse, + GeoPoint, + CorridorStatus, + BypassCorridorOption, + ChokepointExposureSummary, + NumberRange, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { isCallerPremium } from '../../../_shared/premium-check'; +import { cachedFetchJson, getCachedJson } from '../../../_shared/redis'; +import { ROUTE_EXPLORER_LANE_KEY } from '../../../_shared/cache-keys'; +import { CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys'; +import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry'; +import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../_shared/bypass-corridors'; +import type { BypassCorridor, CargoType } from '../../../_shared/bypass-corridors'; +import { TIER_RANK } from './_insurance-tier'; +import COUNTRY_PORT_CLUSTERS from '../../../../scripts/shared/country-port-clusters.json'; +import { TRADE_ROUTES } from '../../../../src/config/trade-routes'; +import { PORTS } from '../../../../src/config/ports'; +import { + TRANSIT_DAYS_BY_ROUTE_ID, + TRANSIT_DAYS_FALLBACK, + FREIGHT_USD_BY_CARGO_TYPE, + FREIGHT_USD_FALLBACK, + getCorridorGeometryOrFallback, +} from './_route-explorer-static-tables'; + +const CACHE_TTL_SECONDS = 60; // matches vendor endpoint cadence + +interface PortClusterEntry { + nearestRouteIds: string[]; + coastSide: string; +} + +interface ChokepointStatus { + id: string; + name?: string; + disruptionScore?: number; + warRiskTier?: string; +} + +interface ChokepointStatusResponse { + chokepoints?: ChokepointStatus[]; +} + +const CARGO_TYPES = new Set(['container', 'tanker', 'bulk', 'roro']); + +const CARGO_TO_ROUTE_CATEGORY: Record = { + container: 'container', + tanker: 'energy', + bulk: 'bulk', + roro: 'container', +}; + +function rankSharedRoutesByCargo( + sharedRoutes: string[], + cargoType: string, +): string[] { + const preferredCategory = CARGO_TO_ROUTE_CATEGORY[cargoType] ?? 'container'; + const routeMap = new Map(TRADE_ROUTES.map((r) => [r.id, r])); + return [...sharedRoutes].sort((a, b) => { + const catA = routeMap.get(a)?.category ?? ''; + const catB = routeMap.get(b)?.category ?? ''; + const matchA = catA === preferredCategory ? 0 : 1; + const matchB = catB === preferredCategory ? 0 : 1; + return matchA - matchB; + }); +} + +function emptyResponse( + req: GetRouteExplorerLaneRequest, + fallbackHs2: string, + fallbackCargo: string, +): GetRouteExplorerLaneResponse { + return { + fromIso2: req.fromIso2, + toIso2: req.toIso2, + hs2: fallbackHs2, + cargoType: fallbackCargo, + primaryRouteId: '', + primaryRouteGeometry: [], + chokepointExposures: [], + bypassOptions: [], + warRiskTier: 'WAR_RISK_TIER_NORMAL', + disruptionScore: 0, + noModeledLane: true, + fetchedAt: new Date().toISOString(), + }; +} + +function rangeOf(tuple: readonly [number, number]): NumberRange { + return { min: tuple[0], max: tuple[1] }; +} + +function geoPoint(lon: number, lat: number): GeoPoint { + return { lon, lat }; +} + +/** + * Resolve coordinates for a `TradeRoute.waypoints` entry. Waypoints are string + * IDs that can refer to either a `PORTS` entry or a chokepoint (via + * `CHOKEPOINT_REGISTRY`). We try both in that order. + */ +function lookupWaypointCoord(waypointId: string): GeoPoint | null { + const port = PORTS.find((p) => p.id === waypointId); + if (port) return geoPoint(port.lon, port.lat); + const cp = CHOKEPOINT_REGISTRY.find((c) => c.id === waypointId); + if (cp) return geoPoint(cp.lon, cp.lat); + return null; +} + +/** + * Build the primaryRouteGeometry polyline from a trade-route definition. We + * use `from` → `waypoints[]` → `to` in sequence, dropping any waypoint we + * can't resolve. Returns an empty array when `routeId` is empty or unknown. + */ +function buildRouteGeometry(routeId: string): GeoPoint[] { + if (!routeId) return []; + const route = TRADE_ROUTES.find((r) => r.id === routeId); + if (!route) return []; + const coords: GeoPoint[] = []; + const fromCoord = lookupWaypointCoord(route.from); + if (fromCoord) coords.push(fromCoord); + for (const wp of route.waypoints) { + const c = lookupWaypointCoord(wp); + if (c) coords.push(c); + } + const toCoord = lookupWaypointCoord(route.to); + if (toCoord) coords.push(toCoord); + return coords; +} + +/** + * Derive a corridor status from the hand-authored `notes` field on the source + * config. We keep this string-matching intentionally narrow to avoid over- + * classifying as proposed/unavailable — default is ACTIVE. + */ +function deriveCorridorStatus(corridor: BypassCorridor): CorridorStatus { + const notes = (corridor.notes ?? '').toLowerCase(); + const name = (corridor.name ?? '').toLowerCase(); + if (/proposed|not yet constructed|notional/.test(notes) || /proposed|\(future\)/.test(name)) { + return 'CORRIDOR_STATUS_PROPOSED'; + } + if (/blockaded|effectively closed|not usable|suspended/.test(notes)) { + return 'CORRIDOR_STATUS_UNAVAILABLE'; + } + return 'CORRIDOR_STATUS_ACTIVE'; +} + +function deriveBypassWarRiskTier( + corridor: BypassCorridor, + statusMap: Map, +): string { + if (corridor.waypointChokepointIds.length > 0) { + return corridor.waypointChokepointIds.reduce((best, id) => { + const t = statusMap.get(id)?.warRiskTier ?? 'WAR_RISK_TIER_UNSPECIFIED'; + return (TIER_RANK[t] ?? 0) > (TIER_RANK[best] ?? 0) ? t : best; + }, 'WAR_RISK_TIER_UNSPECIFIED'); + } + const status = deriveCorridorStatus(corridor); + if (status === 'CORRIDOR_STATUS_UNAVAILABLE') return 'WAR_RISK_TIER_WAR_ZONE'; + return 'WAR_RISK_TIER_UNSPECIFIED'; +} + +function buildBypassOption( + corridor: BypassCorridor, + primaryChokepointId: string, + statusMap: Map, +): BypassCorridorOption { + const geom = getCorridorGeometryOrFallback(corridor.id, primaryChokepointId); + return { + id: corridor.id, + name: corridor.name, + type: corridor.type, + addedTransitDays: corridor.addedTransitDays, + addedCostMultiplier: corridor.addedCostMultiplier, + warRiskTier: deriveBypassWarRiskTier(corridor, statusMap), + status: deriveCorridorStatus(corridor), + fromPort: geoPoint(geom.fromPort[0], geom.fromPort[1]), + toPort: geoPoint(geom.toPort[0], geom.toPort[1]), + }; +} + +/** + * Pure compute function used by the handler and exposed for tests. Does not + * consult premium gating or the response cache. Callers must provide live + * chokepoint status via the parameter; in production the handler fetches it + * from Redis. + */ +export async function computeLane( + req: GetRouteExplorerLaneRequest, + injectedStatusMap?: Map, +): Promise { + const fromIso2 = req.fromIso2.trim().toUpperCase(); + const toIso2 = req.toIso2.trim().toUpperCase(); + const hs2 = req.hs2.trim().replace(/\D/g, '') || '27'; + const cargoLower = req.cargoType.trim().toLowerCase(); + const cargoType = CARGO_TYPES.has(cargoLower) ? cargoLower : 'container'; + + if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) { + return emptyResponse(req, hs2, cargoType); + } + + 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 noModeledLane = sharedRoutes.length === 0; + const rankedRoutes = rankSharedRoutesByCargo(sharedRoutes, cargoType); + const primaryRouteId = rankedRoutes[0] ?? fromCluster?.nearestRouteIds[0] ?? ''; + + let statusMap: Map; + if (injectedStatusMap) { + statusMap = injectedStatusMap; + } else { + const statusRaw = (await getCachedJson(CHOKEPOINT_STATUS_KEY).catch( + () => null, + )) as ChokepointStatusResponse | null; + statusMap = new Map( + (statusRaw?.chokepoints ?? []).map((cp) => [cp.id, cp]), + ); + } + + const primaryRouteSet = new Set(primaryRouteId ? [primaryRouteId] : []); + const chokepointExposures: ChokepointExposureSummary[] = CHOKEPOINT_REGISTRY + .filter((cp) => cp.routeIds.some((r) => primaryRouteSet.has(r))) + .map((cp) => { + const overlap = cp.routeIds.filter((r) => primaryRouteSet.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'; + + const PLACEHOLDER_CORRIDOR_IDS = new Set(['gibraltar_no_bypass', 'cape_of_good_hope_is_bypass']); + const bypassOptions: BypassCorridorOption[] = primaryChokepoint + ? (BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepoint.chokepointId] ?? []) + .filter((c) => { + if (PLACEHOLDER_CORRIDOR_IDS.has(c.id)) return false; + if (c.suitableCargoTypes.length > 0 && !c.suitableCargoTypes.includes(cargoType as CargoType)) return false; + return true; + }) + .slice(0, 5) + .map((c) => buildBypassOption(c, primaryChokepoint.chokepointId, statusMap)) + : []; + + const transitTuple = TRANSIT_DAYS_BY_ROUTE_ID[primaryRouteId] ?? TRANSIT_DAYS_FALLBACK; + const freightTuple = FREIGHT_USD_BY_CARGO_TYPE[cargoType] ?? FREIGHT_USD_FALLBACK; + + return { + fromIso2, + toIso2, + hs2, + cargoType, + primaryRouteId: noModeledLane ? '' : primaryRouteId, + primaryRouteGeometry: noModeledLane ? [] : buildRouteGeometry(primaryRouteId), + chokepointExposures: noModeledLane ? [] : chokepointExposures, + bypassOptions: noModeledLane ? [] : bypassOptions, + warRiskTier: noModeledLane ? 'WAR_RISK_TIER_NORMAL' : warRiskTier, + disruptionScore: noModeledLane ? 0 : disruptionScore, + estTransitDaysRange: noModeledLane ? undefined : rangeOf(transitTuple), + estFreightUsdPerTeuRange: noModeledLane ? undefined : rangeOf(freightTuple), + noModeledLane, + fetchedAt: new Date().toISOString(), + }; +} + +export async function getRouteExplorerLane( + ctx: ServerContext, + req: GetRouteExplorerLaneRequest, +): Promise { + const isPro = await isCallerPremium(ctx.request); + const hs2 = req.hs2?.trim().replace(/\D/g, '') || '27'; + const cargo = CARGO_TYPES.has(req.cargoType?.trim().toLowerCase() ?? '') + ? req.cargoType.trim().toLowerCase() + : 'container'; + if (!isPro) return emptyResponse(req, hs2, cargo); + + const fromIso2 = req.fromIso2?.trim().toUpperCase() ?? ''; + const toIso2 = req.toIso2?.trim().toUpperCase() ?? ''; + if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) { + return emptyResponse(req, hs2, cargo); + } + + const cacheKey = ROUTE_EXPLORER_LANE_KEY(fromIso2, toIso2, hs2, cargo); + const result = await cachedFetchJson( + cacheKey, + CACHE_TTL_SECONDS, + async () => computeLane({ fromIso2, toIso2, hs2, cargoType: cargo }), + ); + return result ?? emptyResponse(req, hs2, cargo); +} diff --git a/server/worldmonitor/supply-chain/v1/handler.ts b/server/worldmonitor/supply-chain/v1/handler.ts index abc1bbb62..66414e4c5 100644 --- a/server/worldmonitor/supply-chain/v1/handler.ts +++ b/server/worldmonitor/supply-chain/v1/handler.ts @@ -8,6 +8,7 @@ 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'; +import { getRouteExplorerLane } from './get-route-explorer-lane'; export const supplyChainHandler: SupplyChainServiceHandler = { getShippingRates, @@ -18,4 +19,5 @@ export const supplyChainHandler: SupplyChainServiceHandler = { getBypassOptions, getCountryCostShock, getSectorDependency, + getRouteExplorerLane, }; 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 37c95236f..39b5de254 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -236,6 +236,60 @@ export interface GetSectorDependencyResponse { fetchedAt: string; } +export interface GetRouteExplorerLaneRequest { + fromIso2: string; + toIso2: string; + hs2: string; + cargoType: string; +} + +export interface GetRouteExplorerLaneResponse { + fromIso2: string; + toIso2: string; + hs2: string; + cargoType: string; + primaryRouteId: string; + primaryRouteGeometry: GeoPoint[]; + chokepointExposures: ChokepointExposureSummary[]; + bypassOptions: BypassCorridorOption[]; + warRiskTier: string; + disruptionScore: number; + estTransitDaysRange?: NumberRange; + estFreightUsdPerTeuRange?: NumberRange; + noModeledLane: boolean; + fetchedAt: string; +} + +export interface GeoPoint { + lon: number; + lat: number; +} + +export interface ChokepointExposureSummary { + chokepointId: string; + chokepointName: string; + exposurePct: number; +} + +export interface BypassCorridorOption { + id: string; + name: string; + type: string; + addedTransitDays: number; + addedCostMultiplier: number; + warRiskTier: string; + status: CorridorStatus; + fromPort?: GeoPoint; + toPort?: GeoPoint; +} + +export interface NumberRange { + min: number; + max: number; +} + +export type CorridorStatus = "CORRIDOR_STATUS_UNSPECIFIED" | "CORRIDOR_STATUS_ACTIVE" | "CORRIDOR_STATUS_PROPOSED" | "CORRIDOR_STATUS_UNAVAILABLE"; + 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"; @@ -486,6 +540,34 @@ export class SupplyChainServiceClient { return await resp.json() as GetSectorDependencyResponse; } + async getRouteExplorerLane(req: GetRouteExplorerLaneRequest, options?: SupplyChainServiceCallOptions): Promise { + let path = "/api/supply-chain/v1/get-route-explorer-lane"; + const params = new URLSearchParams(); + if (req.fromIso2 != null && req.fromIso2 !== "") params.set("fromIso2", String(req.fromIso2)); + if (req.toIso2 != null && req.toIso2 !== "") params.set("toIso2", String(req.toIso2)); + if (req.hs2 != null && req.hs2 !== "") params.set("hs2", String(req.hs2)); + if (req.cargoType != null && req.cargoType !== "") params.set("cargoType", String(req.cargoType)); + 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 GetRouteExplorerLaneResponse; + } + 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 e1476a519..b5f6ff0db 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -236,6 +236,60 @@ export interface GetSectorDependencyResponse { fetchedAt: string; } +export interface GetRouteExplorerLaneRequest { + fromIso2: string; + toIso2: string; + hs2: string; + cargoType: string; +} + +export interface GetRouteExplorerLaneResponse { + fromIso2: string; + toIso2: string; + hs2: string; + cargoType: string; + primaryRouteId: string; + primaryRouteGeometry: GeoPoint[]; + chokepointExposures: ChokepointExposureSummary[]; + bypassOptions: BypassCorridorOption[]; + warRiskTier: string; + disruptionScore: number; + estTransitDaysRange?: NumberRange; + estFreightUsdPerTeuRange?: NumberRange; + noModeledLane: boolean; + fetchedAt: string; +} + +export interface GeoPoint { + lon: number; + lat: number; +} + +export interface ChokepointExposureSummary { + chokepointId: string; + chokepointName: string; + exposurePct: number; +} + +export interface BypassCorridorOption { + id: string; + name: string; + type: string; + addedTransitDays: number; + addedCostMultiplier: number; + warRiskTier: string; + status: CorridorStatus; + fromPort?: GeoPoint; + toPort?: GeoPoint; +} + +export interface NumberRange { + min: number; + max: number; +} + +export type CorridorStatus = "CORRIDOR_STATUS_UNSPECIFIED" | "CORRIDOR_STATUS_ACTIVE" | "CORRIDOR_STATUS_PROPOSED" | "CORRIDOR_STATUS_UNAVAILABLE"; + 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"; @@ -293,6 +347,7 @@ export interface SupplyChainServiceHandler { getBypassOptions(ctx: ServerContext, req: GetBypassOptionsRequest): Promise; getCountryCostShock(ctx: ServerContext, req: GetCountryCostShockRequest): Promise; getSectorDependency(ctx: ServerContext, req: GetSectorDependencyRequest): Promise; + getRouteExplorerLane(ctx: ServerContext, req: GetRouteExplorerLaneRequest): Promise; } export function createSupplyChainServiceRoutes( @@ -642,6 +697,56 @@ export function createSupplyChainServiceRoutes( } }, }, + { + method: "GET", + path: "/api/supply-chain/v1/get-route-explorer-lane", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetRouteExplorerLaneRequest = { + fromIso2: params.get("fromIso2") ?? "", + toIso2: params.get("toIso2") ?? "", + hs2: params.get("hs2") ?? "", + cargoType: params.get("cargoType") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getRouteExplorerLane", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getRouteExplorerLane(ctx, body); + return new Response(JSON.stringify(result as GetRouteExplorerLaneResponse), { + 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" }, + }); + } + }, + }, ]; } diff --git a/src/services/supply-chain/index.ts b/src/services/supply-chain/index.ts index f65081c6c..a2305fee6 100644 --- a/src/services/supply-chain/index.ts +++ b/src/services/supply-chain/index.ts @@ -10,6 +10,7 @@ import { type GetBypassOptionsResponse, type GetCountryCostShockResponse, type GetSectorDependencyResponse, + type GetRouteExplorerLaneResponse, type ShippingIndex, type ChokepointInfo, type CriticalMineral, @@ -30,6 +31,7 @@ export type { GetBypassOptionsResponse, GetCountryCostShockResponse, GetSectorDependencyResponse, + GetRouteExplorerLaneResponse, ShippingIndex, ChokepointInfo, CriticalMineral, @@ -226,6 +228,35 @@ export async function fetchSectorDependency( } } +const emptyRouteExplorerLane: GetRouteExplorerLaneResponse = { + fromIso2: '', toIso2: '', hs2: '', cargoType: '', + primaryRouteId: '', + primaryRouteGeometry: [], + chokepointExposures: [], + bypassOptions: [], + warRiskTier: 'WAR_RISK_TIER_NORMAL', + disruptionScore: 0, + noModeledLane: true, + fetchedAt: '', +}; + +export interface FetchRouteExplorerLaneArgs { + fromIso2: string; + toIso2: string; + hs2: string; + cargoType: string; +} + +export async function fetchRouteExplorerLane( + args: FetchRouteExplorerLaneArgs, +): Promise { + try { + return await client.getRouteExplorerLane(args); + } catch { + return { ...emptyRouteExplorerLane, ...args }; + } +} + export interface ProductExporter { partnerCode: number; partnerIso2: string; diff --git a/src/shared/premium-paths.ts b/src/shared/premium-paths.ts index 8f8feefe1..c347f88c2 100644 --- a/src/shared/premium-paths.ts +++ b/src/shared/premium-paths.ts @@ -19,6 +19,7 @@ export const PREMIUM_RPC_PATHS = new Set([ '/api/supply-chain/v1/get-country-chokepoint-index', '/api/supply-chain/v1/get-bypass-options', '/api/supply-chain/v1/get-country-cost-shock', + '/api/supply-chain/v1/get-route-explorer-lane', '/api/supply-chain/v1/multi-sector-cost-shock', '/api/economic/v1/get-national-debt', '/api/sanctions/v1/list-sanctions-pressure', diff --git a/tests/route-explorer-lane.test.mts b/tests/route-explorer-lane.test.mts new file mode 100644 index 000000000..fa2873c6c --- /dev/null +++ b/tests/route-explorer-lane.test.mts @@ -0,0 +1,325 @@ +/** + * Smoke test matrix for `get-route-explorer-lane`. + * + * Calls the pure `computeLane` function (no Redis, no premium gate) for 30 + * representative country pairs × HS2 codes and asserts on response *structure* + * — not hard-coded transit/cost values, which would drift as the underlying + * static tables change. + * + * The matrix also doubles as a gap report: any pair with empty + * `chokepointExposures` or `bypassOptions` is logged so Sprint 3/5 can plan + * empty-state work. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { computeLane } from '../server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts'; +import type { GetRouteExplorerLaneRequest } from '../src/generated/server/worldmonitor/supply_chain/v1/service_server.ts'; + +const PAIRS: Array<[string, string, string]> = [ + ['CN', 'DE', 'high-volume baseline'], + ['US', 'JP', 'transpacific'], + ['IR', 'CN', 'Hormuz-dependent'], + ['BR', 'NL', 'Atlantic'], + ['AU', 'KR', 'Pacific'], + ['ZA', 'IN', 'Cape of Good Hope'], + ['EG', 'IT', 'Mediterranean short-haul'], + ['NG', 'CN', 'Africa to Asia crude'], + ['CL', 'CN', 'South America to Asia copper'], + ['TR', 'DE', 'semi-landlocked, tests land-bridge path'], +]; + +const HS2_CODES = ['27', '85', '10']; + +const VALID_WAR_RISK_TIERS = new Set([ + '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', +]); + +const VALID_STATUSES = new Set([ + 'CORRIDOR_STATUS_UNSPECIFIED', + 'CORRIDOR_STATUS_ACTIVE', + 'CORRIDOR_STATUS_PROPOSED', + 'CORRIDOR_STATUS_UNAVAILABLE', +]); + +interface GapRow { + pair: string; + hs2: string; + primaryRouteId: string; + noModeledLane: boolean; + exposures: number; + bypasses: number; + reason: string; +} + +const gapRows: GapRow[] = []; + +describe('get-route-explorer-lane smoke matrix (30 queries)', () => { + for (const [fromIso2, toIso2, reason] of PAIRS) { + for (const hs2 of HS2_CODES) { + it(`${fromIso2} -> ${toIso2}, HS ${hs2} (${reason})`, async () => { + const req: GetRouteExplorerLaneRequest = { + fromIso2, + toIso2, + hs2, + cargoType: hs2 === '27' ? 'tanker' : hs2 === '10' ? 'bulk' : 'container', + }; + + // Pass an empty chokepoint-status map so the test does not depend on + // a live Redis cache. War risk + disruption come back as defaults. + const res = await computeLane(req, new Map()); + + // Echoed inputs + assert.equal(res.fromIso2, fromIso2); + assert.equal(res.toIso2, toIso2); + assert.equal(res.hs2, hs2); + + // Cargo type echoed and valid + assert.match(res.cargoType, /^(container|tanker|bulk|roro)$/); + + // primaryRouteId is either non-empty OR noModeledLane is set + if (!res.primaryRouteId) { + assert.equal( + res.noModeledLane, + true, + 'empty primaryRouteId requires noModeledLane=true', + ); + } + + // primaryRouteGeometry is an array (may be empty when no modeled lane) + assert.ok(Array.isArray(res.primaryRouteGeometry)); + for (const pt of res.primaryRouteGeometry) { + assert.equal(typeof pt.lon, 'number'); + assert.equal(typeof pt.lat, 'number'); + assert.ok(Number.isFinite(pt.lon)); + assert.ok(Number.isFinite(pt.lat)); + } + + // chokepointExposures is an array of well-formed entries + assert.ok(Array.isArray(res.chokepointExposures)); + for (const e of res.chokepointExposures) { + assert.equal(typeof e.chokepointId, 'string'); + assert.equal(typeof e.chokepointName, 'string'); + assert.equal(typeof e.exposurePct, 'number'); + assert.ok(e.exposurePct >= 0 && e.exposurePct <= 100); + } + + // bypassOptions is an array of well-formed entries + assert.ok(Array.isArray(res.bypassOptions)); + for (const b of res.bypassOptions) { + assert.equal(typeof b.id, 'string'); + assert.equal(typeof b.name, 'string'); + assert.equal(typeof b.type, 'string'); + assert.equal(typeof b.addedTransitDays, 'number'); + assert.equal(typeof b.addedCostMultiplier, 'number'); + assert.ok(VALID_STATUSES.has(b.status)); + assert.ok(b.fromPort, 'bypass option must include fromPort'); + assert.ok(b.toPort, 'bypass option must include toPort'); + assert.equal(typeof b.fromPort.lon, 'number'); + assert.equal(typeof b.fromPort.lat, 'number'); + assert.equal(typeof b.toPort.lon, 'number'); + assert.equal(typeof b.toPort.lat, 'number'); + assert.ok(Number.isFinite(b.fromPort.lon)); + assert.ok(Number.isFinite(b.toPort.lon)); + } + + // war risk tier is in the known enum set + assert.ok( + VALID_WAR_RISK_TIERS.has(res.warRiskTier), + `unexpected warRiskTier: ${res.warRiskTier}`, + ); + + // disruption score is a finite number in [0, 100] + assert.equal(typeof res.disruptionScore, 'number'); + assert.ok(res.disruptionScore >= 0 && res.disruptionScore <= 100); + + // transit + freight ranges: present and well-formed when lane is modeled; + // omitted when noModeledLane is true (no synthetic estimates) + if (!res.noModeledLane) { + assert.ok(res.estTransitDaysRange, 'modeled lane must include transit range'); + assert.ok(res.estFreightUsdPerTeuRange, 'modeled lane must include freight range'); + assert.ok(Number.isFinite(res.estTransitDaysRange.min)); + assert.ok(Number.isFinite(res.estTransitDaysRange.max)); + assert.ok(res.estTransitDaysRange.min <= res.estTransitDaysRange.max); + assert.ok(res.estFreightUsdPerTeuRange.min <= res.estFreightUsdPerTeuRange.max); + } else { + assert.equal(res.primaryRouteId, '', 'noModeledLane must have empty primaryRouteId'); + assert.equal(res.primaryRouteGeometry.length, 0, 'noModeledLane must have empty geometry'); + assert.equal(res.chokepointExposures.length, 0, 'noModeledLane must have empty exposures'); + assert.equal(res.bypassOptions.length, 0, 'noModeledLane must have empty bypasses'); + } + + // fetchedAt is an ISO string + assert.equal(typeof res.fetchedAt, 'string'); + assert.ok(res.fetchedAt.length > 0); + + // Record gap-report metadata for the run summary + gapRows.push({ + pair: `${fromIso2}->${toIso2}`, + hs2, + primaryRouteId: res.primaryRouteId, + noModeledLane: res.noModeledLane, + exposures: res.chokepointExposures.length, + bypasses: res.bypassOptions.length, + reason, + }); + }); + } + } + + it('gap report summary (informational, never fails)', () => { + // Print a compact gap report so plan reviewers can see which pairs + // returned synthetic / empty data. + if (gapRows.length === 0) { + // No-op when run before the matrix above (test ordering is preserved) + return; + } + const noLane = gapRows.filter((r) => r.noModeledLane); + const emptyExposures = gapRows.filter((r) => r.exposures === 0); + const emptyBypasses = gapRows.filter((r) => r.bypasses === 0); + // eslint-disable-next-line no-console + console.log( + `\n[gap report] ${gapRows.length} queries | ${noLane.length} synthetic-fallback | ${emptyExposures.length} empty exposures | ${emptyBypasses.length} empty bypasses`, + ); + if (noLane.length > 0) { + // eslint-disable-next-line no-console + console.log(' synthetic-fallback pairs:'); + for (const r of noLane) { + // eslint-disable-next-line no-console + console.log(` ${r.pair} HS${r.hs2} -> ${r.primaryRouteId || '(none)'}`); + } + } + // eslint-disable-next-line no-console + console.log( + '\n[design gap] bypassOptions are only computed for the primary chokepoint (highest exposurePct).' + + '\nMulti-chokepoint routes (e.g. CN->DE via Malacca + Suez) show exposure data for both but' + + '\nbypass guidance only for the primary one. Sprint 3 should decide: expand to top-N chokepoints,' + + '\nor show a "see also" hint in the UI.', + ); + // Always passes; informational only. + assert.ok(true); + }); + + it('cargo-aware route selection: CN->JP tanker picks energy route over container', async () => { + const res = await computeLane( + { fromIso2: 'CN', toIso2: 'JP', hs2: '27', cargoType: 'tanker' }, + new Map(), + ); + if (!res.noModeledLane && res.primaryRouteId) { + const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts'); + const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId); + assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`); + assert.equal( + route.category, + 'energy', + `tanker request should prefer an energy route, got ${route.category} (${res.primaryRouteId})`, + ); + } + }); + + it('bypass warRiskTier derives from waypoint chokepoints, not primary', async () => { + const fakeStatus = new Map([ + ['suez', { id: 'suez', warRiskTier: 'WAR_RISK_TIER_CRITICAL' }], + ['cape_of_good_hope', { id: 'cape_of_good_hope', warRiskTier: 'WAR_RISK_TIER_NORMAL' }], + ]); + const res = await computeLane( + { fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' }, + fakeStatus as Map, + ); + const capeBypass = res.bypassOptions.find((b) => b.id === 'suez_cape_of_good_hope'); + if (capeBypass) { + assert.equal( + capeBypass.warRiskTier, + 'WAR_RISK_TIER_NORMAL', + 'Cape bypass should reflect its own waypoint risk (NORMAL), not the primary chokepoint (CRITICAL)', + ); + } + }); + + it('placeholder corridors are excluded but proposed zero-day corridors survive', async () => { + const res = await computeLane( + { fromIso2: 'ES', toIso2: 'EG', hs2: '85', cargoType: 'container' }, + new Map(), + ); + const placeholder = res.bypassOptions.find((b) => + b.id === 'gibraltar_no_bypass' || b.id === 'cape_of_good_hope_is_bypass', + ); + assert.equal(placeholder, undefined, 'explicit placeholder corridors should be filtered out'); + }); + + it('kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes', async () => { + const res = await computeLane( + { fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' }, + new Map(), + ); + const kra = res.bypassOptions.find((b) => b.id === 'kra_canal_future'); + if (kra) { + assert.equal( + kra.status, + 'CORRIDOR_STATUS_PROPOSED', + 'kra_canal_future should be surfaced as proposed, not filtered out', + ); + } + }); + + it('disruptionScore and warRiskTier reflect injected status map', async () => { + const fakeStatus = new Map([ + ['suez', { id: 'suez', disruptionScore: 75, warRiskTier: 'WAR_RISK_TIER_HIGH' }], + ['malacca_strait', { id: 'malacca_strait', disruptionScore: 30, warRiskTier: 'WAR_RISK_TIER_ELEVATED' }], + ]); + const res = await computeLane( + { fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' }, + fakeStatus as Map, + ); + if (res.noModeledLane) return; + assert.ok(res.disruptionScore > 0, 'disruptionScore should reflect injected data, not default to 0'); + assert.notEqual(res.warRiskTier, 'WAR_RISK_TIER_NORMAL', 'warRiskTier should reflect injected data'); + }); + + it('unavailable corridor without waypoints gets WAR_RISK_TIER_WAR_ZONE', async () => { + const fakeStatus = new Map([ + ['kerch_strait', { id: 'kerch_strait', warRiskTier: 'WAR_RISK_TIER_WAR_ZONE' }], + ]); + const res = await computeLane( + { fromIso2: 'RU', toIso2: 'TR', hs2: '27', cargoType: 'tanker' }, + fakeStatus as Map, + ); + const unavailable = res.bypassOptions.find((b) => b.status === 'CORRIDOR_STATUS_UNAVAILABLE'); + if (unavailable) { + assert.equal( + unavailable.warRiskTier, + 'WAR_RISK_TIER_WAR_ZONE', + 'unavailable corridors without waypoints should derive WAR_ZONE from status', + ); + } + }); + + it('chokepointExposures and bypassOptions follow the primaryRouteId', async () => { + const res = await computeLane( + { fromIso2: 'CN', toIso2: 'JP', hs2: '85', cargoType: 'container' }, + new Map(), + ); + if (res.noModeledLane || !res.primaryRouteId) return; + const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts'); + const { CHOKEPOINT_REGISTRY } = await import('../server/_shared/chokepoint-registry.ts'); + const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId); + assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`); + const routeChokepointIds = new Set( + CHOKEPOINT_REGISTRY + .filter((cp: { routeIds: string[] }) => cp.routeIds.includes(res.primaryRouteId)) + .map((cp: { id: string }) => cp.id), + ); + for (const exp of res.chokepointExposures) { + assert.ok( + routeChokepointIds.has(exp.chokepointId), + `chokepoint ${exp.chokepointId} is not on the primary route ${res.primaryRouteId}`, + ); + } + }); +});