diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index 574573ea5..db3fde1a9 100644 --- a/docs/api/SupplyChainService.openapi.json +++ b/docs/api/SupplyChainService.openapi.json @@ -1 +1 @@ -{"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"},"GetRouteImpactRequest":{"properties":{"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2"],"type":"object"},"GetRouteImpactResponse":{"properties":{"comtradeSource":{"type":"string"},"dependencyFlags":{"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"},"fetchedAt":{"type":"string"},"hs2InSeededUniverse":{"type":"boolean"},"laneValueUsd":{"format":"double","type":"number"},"primaryExporterIso2":{"type":"string"},"primaryExporterShare":{"format":"double","type":"number"},"resilienceScore":{"format":"double","type":"number"},"topStrategicProducts":{"items":{"$ref":"#/components/schemas/StrategicProduct"},"type":"array"}},"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"},"StrategicProduct":{"properties":{"hs4":{"type":"string"},"label":{"type":"string"},"primaryChokepointId":{"type":"string"},"topExporterIso2":{"type":"string"},"topExporterShare":{"format":"double","type":"number"},"totalValueUsd":{"format":"double","type":"number"}},"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-route-impact":{"get":{"operationId":"GetRouteImpact","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteImpactResponse"}}},"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":"GetRouteImpact","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"},"GetChokepointHistoryRequest":{"description":"GetChokepointHistory returns the transit-count history for a single\n chokepoint. Loaded lazily on card expand so the main chokepoint-status\n response can stay compact (no 180-day history per chokepoint).","properties":{"chokepointId":{"type":"string"}},"required":["chokepointId"],"type":"object"},"GetChokepointHistoryResponse":{"properties":{"chokepointId":{"type":"string"},"fetchedAt":{"format":"int64","type":"string"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"}},"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"},"GetRouteImpactRequest":{"properties":{"fromIso2":{"pattern":"^[A-Z]{2}$","type":"string"},"hs2":{"type":"string"},"toIso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["fromIso2","toIso2","hs2"],"type":"object"},"GetRouteImpactResponse":{"properties":{"comtradeSource":{"type":"string"},"dependencyFlags":{"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"},"fetchedAt":{"type":"string"},"hs2InSeededUniverse":{"type":"boolean"},"laneValueUsd":{"format":"double","type":"number"},"primaryExporterIso2":{"type":"string"},"primaryExporterShare":{"format":"double","type":"number"},"resilienceScore":{"format":"double","type":"number"},"topStrategicProducts":{"items":{"$ref":"#/components/schemas/StrategicProduct"},"type":"array"}},"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"},"StrategicProduct":{"properties":{"hs4":{"type":"string"},"label":{"type":"string"},"primaryChokepointId":{"type":"string"},"topExporterIso2":{"type":"string"},"topExporterShare":{"format":"double","type":"number"},"totalValueUsd":{"format":"double","type":"number"}},"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-history":{"get":{"description":"GetChokepointHistory returns transit-count history for a single chokepoint,\n loaded lazily on card expand. Keeps the status RPC compact (no 180-day\n history per chokepoint on every call).","operationId":"GetChokepointHistory","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointHistoryResponse"}}},"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":"GetChokepointHistory","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-route-impact":{"get":{"operationId":"GetRouteImpact","parameters":[{"in":"query","name":"fromIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"toIso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetRouteImpactResponse"}}},"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":"GetRouteImpact","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 b7e8b7f53..733feb172 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -53,6 +53,41 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/supply-chain/v1/get-chokepoint-history: + get: + tags: + - SupplyChainService + summary: GetChokepointHistory + description: |- + GetChokepointHistory returns transit-count history for a single chokepoint, + loaded lazily on card expand. Keeps the status RPC compact (no 180-day + history per chokepoint on every call). + operationId: GetChokepointHistory + parameters: + - name: chokepointId + in: query + required: false + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetChokepointHistoryResponse' + "400": + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + default: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/supply-chain/v1/get-critical-minerals: get: tags: @@ -623,6 +658,29 @@ components: type: string hazardAlertName: type: string + GetChokepointHistoryRequest: + type: object + properties: + chokepointId: + type: string + required: + - chokepointId + description: |- + GetChokepointHistory returns the transit-count history for a single + chokepoint. Loaded lazily on card expand so the main chokepoint-status + response can stay compact (no 180-day history per chokepoint). + GetChokepointHistoryResponse: + type: object + properties: + chokepointId: + type: string + history: + type: array + items: + $ref: '#/components/schemas/TransitDayCount' + fetchedAt: + type: string + format: int64 GetCriticalMineralsRequest: type: object GetCriticalMineralsResponse: diff --git a/proto/worldmonitor/supply_chain/v1/get_chokepoint_history.proto b/proto/worldmonitor/supply_chain/v1/get_chokepoint_history.proto new file mode 100644 index 000000000..f84f27be8 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_chokepoint_history.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/supply_chain/v1/supply_chain_data.proto"; + +// GetChokepointHistory returns the transit-count history for a single +// chokepoint. Loaded lazily on card expand so the main chokepoint-status +// response can stay compact (no 180-day history per chokepoint). +message GetChokepointHistoryRequest { + string chokepoint_id = 1 [ + (buf.validate.field).required = true, + (sebuf.http.query) = {name: "chokepointId"} + ]; +} + +message GetChokepointHistoryResponse { + string chokepoint_id = 1; + repeated TransitDayCount history = 2; + int64 fetched_at = 3; +} diff --git a/proto/worldmonitor/supply_chain/v1/service.proto b/proto/worldmonitor/supply_chain/v1/service.proto index 1316dca7c..c57a958d9 100644 --- a/proto/worldmonitor/supply_chain/v1/service.proto +++ b/proto/worldmonitor/supply_chain/v1/service.proto @@ -5,6 +5,7 @@ package worldmonitor.supply_chain.v1; import "sebuf/http/annotations.proto"; import "worldmonitor/supply_chain/v1/get_shipping_rates.proto"; import "worldmonitor/supply_chain/v1/get_chokepoint_status.proto"; +import "worldmonitor/supply_chain/v1/get_chokepoint_history.proto"; import "worldmonitor/supply_chain/v1/get_critical_minerals.proto"; import "worldmonitor/supply_chain/v1/get_shipping_stress.proto"; import "worldmonitor/supply_chain/v1/get_country_chokepoint_index.proto"; @@ -25,6 +26,13 @@ service SupplyChainService { option (sebuf.http.config) = {path: "/get-chokepoint-status", method: HTTP_METHOD_GET}; } + // GetChokepointHistory returns transit-count history for a single chokepoint, + // loaded lazily on card expand. Keeps the status RPC compact (no 180-day + // history per chokepoint on every call). + rpc GetChokepointHistory(GetChokepointHistoryRequest) returns (GetChokepointHistoryResponse) { + option (sebuf.http.config) = {path: "/get-chokepoint-history", method: HTTP_METHOD_GET}; + } + rpc GetCriticalMinerals(GetCriticalMineralsRequest) returns (GetCriticalMineralsResponse) { option (sebuf.http.config) = {path: "/get-critical-minerals", method: HTTP_METHOD_GET}; } diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index 821d8d84c..203998075 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -7367,7 +7367,13 @@ setInterval(() => { }, CHOKEPOINT_TRANSIT_INTERVAL_MS).unref?.(); // --- Pre-assembled Transit Summaries (Railway advantage: avoids large Redis reads on Vercel) --- +// Split storage: compact summary (no history, ~30KB) + per-id history keys (~35KB each). +// The compact summary is read on every /api/supply-chain/v1/get-chokepoint-status call. +// History keys are read only on card expand via /get-chokepoint-history. Before this +// split the combined payload was ~500KB and timed out at Vercel edge's 1.5s Redis read +// budget (docs/plans/chokepoint-rpc-payload-split.md). const TRANSIT_SUMMARY_REDIS_KEY = 'supply_chain:transit-summaries:v1'; +const TRANSIT_SUMMARY_HISTORY_KEY_PREFIX = 'supply_chain:transit-summaries:history:v1:'; const TRANSIT_SUMMARY_TTL = 3600; // 1h — 6x interval; survives ~5 consecutive missed pings const TRANSIT_SUMMARY_INTERVAL_MS = 10 * 60 * 1000; @@ -7424,10 +7430,22 @@ async function seedTransitSummaries() { const now = Date.now(); const summaries = {}; + // Iterate the canonical chokepoint ID set rather than whatever pw happens to + // carry today. If seed-portwatch dropped 3 of 13 (flaky ArcGIS), those 3 + // would otherwise vanish from summaries and the RPC would render zero-state + // rows for them — which get-chokepoint-status treats as healthy because its + // upstreamUnavailable gate fires only on fully-empty summaries. By emitting + // all 13 with zero-state for missing IDs, the shape is consistent and the + // coverage shortfall surfaces via the `pwCovered/N` log + recordCount only. + const CANONICAL_IDS = Object.keys(CHOKEPOINT_THREAT_LEVELS); + let pwCovered = 0; - for (const [cpId, cpData] of Object.entries(pw)) { + for (const cpId of CANONICAL_IDS) { + const cpData = pw[cpId]; + if (cpData) pwCovered++; const threatLevel = CHOKEPOINT_THREAT_LEVELS[cpId] || 'normal'; - const anomaly = detectTrafficAnomalyRelay(cpData.history, threatLevel); + const history = cpData?.history ?? []; + const anomaly = detectTrafficAnomalyRelay(history, threatLevel); // Get relay transit counts for this chokepoint let relayTransit = null; @@ -7448,13 +7466,15 @@ async function seedTransitSummaries() { } const cr = latestCorridorRiskData?.[cpId]; + + // Compact summary: no history field. Consumed by get-chokepoint-status on + // every request, so keep it small. summaries[cpId] = { todayTotal: relayTransit?.total ?? 0, todayTanker: relayTransit?.tanker ?? 0, todayCargo: relayTransit?.cargo ?? 0, todayOther: relayTransit?.other ?? 0, - wowChangePct: cpData.wowChangePct ?? 0, - history: cpData.history ?? [], + wowChangePct: cpData?.wowChangePct ?? 0, riskLevel: cr?.riskLevel ?? '', incidentCount7d: cr?.incidentCount7d ?? 0, disruptionPct: cr?.disruptionPct ?? 0, @@ -7462,11 +7482,31 @@ async function seedTransitSummaries() { riskReportAction: cr?.riskReportAction ?? '', anomaly, }; + + // Per-id history key — only fetched on card expand via GetChokepointHistory. + // Write best-effort: a failure here doesn't block the summary publish. An + // empty history key just means the chart is unavailable for that chokepoint + // until the next successful relay tick. + const historyPayload = { chokepointId: cpId, history, fetchedAt: now }; + const historyOk = await envelopeWrite( + `${TRANSIT_SUMMARY_HISTORY_KEY_PREFIX}${cpId}`, + historyPayload, + TRANSIT_SUMMARY_TTL, + { recordCount: history.length, sourceVersion: 'transit-summaries-history' }, + ); + if (!historyOk) console.warn(`[TransitSummary] history write failed for ${cpId}`); } - const ok = await envelopeWrite(TRANSIT_SUMMARY_REDIS_KEY, { summaries, fetchedAt: now }, TRANSIT_SUMMARY_TTL, { recordCount: Object.keys(summaries).length, sourceVersion: 'transit-summaries' }); - await upstashSet('seed-meta:supply_chain:transit-summaries', { fetchedAt: now, recordCount: Object.keys(summaries).length }, 604800); - console.log(`[TransitSummary] Seeded ${Object.keys(summaries).length} summaries (redis: ${ok ? 'OK' : 'FAIL'})`); + if (pwCovered < CANONICAL_IDS.length) { + console.warn(`[TransitSummary] portwatch coverage shortfall: ${pwCovered}/${CANONICAL_IDS.length} — missing chokepoints will publish zero-state until next upstream success`); + } + + const ok = await envelopeWrite(TRANSIT_SUMMARY_REDIS_KEY, { summaries, fetchedAt: now }, TRANSIT_SUMMARY_TTL, { recordCount: pwCovered, sourceVersion: 'transit-summaries' }); + // seed-meta recordCount = pwCovered (actual upstream coverage), not the + // canonical-shape key count. Lets api/health.js detect a coverage shortfall + // as a freshness anomaly rather than being masked by the always-13 shape. + await upstashSet('seed-meta:supply_chain:transit-summaries', { fetchedAt: now, recordCount: pwCovered }, 604800); + console.log(`[TransitSummary] Seeded ${pwCovered}/${CANONICAL_IDS.length} from portwatch + per-id history (redis: ${ok ? 'OK' : 'FAIL'})`); } // Seed transit summaries every 10 min (same as transit counter) diff --git a/server/_shared/redis.ts b/server/_shared/redis.ts index 024606cf7..a58561e61 100644 --- a/server/_shared/redis.ts +++ b/server/_shared/redis.ts @@ -80,7 +80,17 @@ export async function getCachedJson(key: string, raw = false): Promise = { '/api/forecast/v1/get-simulation-package': 'slow', '/api/forecast/v1/get-simulation-outcome': 'slow', '/api/supply-chain/v1/get-chokepoint-status': 'medium', + '/api/supply-chain/v1/get-chokepoint-history': 'slow', '/api/news/v1/list-feed-digest': 'slow', '/api/intelligence/v1/get-country-facts': 'daily', '/api/intelligence/v1/list-security-advisories': 'slow', diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-history.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-history.ts new file mode 100644 index 000000000..c5e6705d9 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-history.ts @@ -0,0 +1,42 @@ +import type { + ServerContext, + GetChokepointHistoryRequest, + GetChokepointHistoryResponse, + TransitDayCount, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { getCachedJson } from '../../../_shared/redis'; +import { CANONICAL_CHOKEPOINTS } from './_chokepoint-ids'; + +const HISTORY_KEY_PREFIX = 'supply_chain:transit-summaries:history:v1:'; +const VALID_IDS = new Set(CANONICAL_CHOKEPOINTS.map(c => c.id)); + +interface HistoryPayload { + chokepointId: string; + history: TransitDayCount[]; + fetchedAt: number; +} + +export async function getChokepointHistory( + _ctx: ServerContext, + req: GetChokepointHistoryRequest, +): Promise { + const id = String(req.chokepointId || '').trim(); + if (!id || !VALID_IDS.has(id)) { + return { chokepointId: '', history: [], fetchedAt: '0' }; + } + + try { + const payload = await getCachedJson(`${HISTORY_KEY_PREFIX}${id}`, true) as HistoryPayload | null; + if (!payload || !Array.isArray(payload.history)) { + return { chokepointId: id, history: [], fetchedAt: '0' }; + } + return { + chokepointId: id, + history: payload.history, + fetchedAt: String(payload.fetchedAt ?? 0), + }; + } catch { + return { chokepointId: id, history: [], fetchedAt: '0' }; + } +} diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts index 7a0c0c77d..8f50122f8 100644 --- a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -15,17 +15,19 @@ import type { import { cachedFetchJson, getCachedJson, setCachedJson } from '../../../_shared/redis'; import { listNavigationalWarnings } from '../../maritime/v1/list-navigational-warnings'; import { getVesselSnapshot } from '../../maritime/v1/get-vessel-snapshot'; -import type { PortWatchData } from './_portwatch-upstream'; -import { CANONICAL_CHOKEPOINTS } from './_chokepoint-ids'; // @ts-expect-error — .mjs module, no declaration file -import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL, detectTrafficAnomaly } from './_scoring.mjs'; +import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL } from './_scoring.mjs'; import { type ThreatLevel, threatLevelToWarRiskTier } from './_insurance-tier'; import { CHOKEPOINT_STATUS_KEY as REDIS_CACHE_KEY } from '../../../_shared/cache-keys'; const TRANSIT_SUMMARIES_KEY = 'supply_chain:transit-summaries:v1'; -const PORTWATCH_FALLBACK_KEY = 'supply_chain:portwatch:v1'; -const CORRIDORRISK_FALLBACK_KEY = 'supply_chain:corridorrisk:v1'; -const TRANSIT_COUNTS_FALLBACK_KEY = 'supply_chain:chokepoint_transits:v1'; const FLOWS_KEY = 'energy:chokepoint-flows:v1'; +// NOTE: historical fallback via supply_chain:portwatch:v1 / corridorrisk / chokepoint_transits +// was removed — those keys are ~500KB each, and reading them on top of the already-large +// transit-summaries payload was causing Vercel-edge Redis timeouts (1.5s budget) and pinning +// a silent zero-state cache. Today the ais-relay writer is authoritative for the compact +// summary; if it's missing we fail-fast via upstreamUnavailable so cachedFetchJson writes +// NEG_SENTINEL (120s) instead of caching a fake 5-min healthy-but-empty response. +// See docs/plans/chokepoint-rpc-payload-split.md. const REDIS_CACHE_TTL = 300; // 5 min const THREAT_CONFIG_MAX_AGE_DAYS = 120; const NEARBY_CHOKEPOINT_RADIUS_KM = 300; @@ -67,13 +69,15 @@ interface ChokepointConfig { type DirectionLabel = 'eastbound' | 'westbound' | 'northbound' | 'southbound'; +// Compact summary written by ais-relay.cjs — no history array; per-id history +// lives in `supply_chain:transit-summaries:history:v1:{id}` and is served by +// GetChokepointHistory on card expand. interface PreBuiltTransitSummary { todayTotal: number; todayTanker: number; todayCargo: number; todayOther: number; wowChangePct: number; - history: import('./_portwatch-upstream').TransitDayCount[]; riskLevel: string; incidentCount7d: number; disruptionPct: number; @@ -239,47 +243,7 @@ interface ChokepointFetchResult { upstreamUnavailable: boolean; } -interface CorridorRiskEntry { riskLevel: string; incidentCount7d: number; disruptionPct: number; riskSummary: string; riskReportAction: string } -interface RelayTransitEntry { tanker: number; cargo: number; other: number; total: number } interface FlowEstimateEntry { currentMbd: number; baselineMbd: number; flowRatio: number; disrupted: boolean; source: string; hazardAlertLevel: string | null; hazardAlertName: string | null } -interface RelayTransitPayload { transits: Record; fetchedAt: number } - -function buildFallbackSummaries( - portwatch: PortWatchData | null, - corridorRisk: Record | null, - transitData: RelayTransitPayload | null, - chokepoints: ChokepointConfig[], -): Record { - const summaries: Record = {}; - const relayMap = new Map(); - if (transitData?.transits) { - for (const [relayName, entry] of Object.entries(transitData.transits)) { - const canonical = CANONICAL_CHOKEPOINTS.find(c => c.relayName === relayName); - if (canonical) relayMap.set(canonical.id, entry); - } - } - for (const cp of chokepoints) { - const pw = portwatch?.[cp.id]; - const cr = corridorRisk?.[cp.id]; - const relay = relayMap.get(cp.id); - const anomaly = detectTrafficAnomaly(pw?.history ?? [], cp.threatLevel); - summaries[cp.id] = { - todayTotal: relay?.total ?? 0, - todayTanker: relay?.tanker ?? 0, - todayCargo: relay?.cargo ?? 0, - todayOther: relay?.other ?? 0, - wowChangePct: pw?.wowChangePct ?? 0, - history: pw?.history ?? [], - riskLevel: cr?.riskLevel ?? '', - incidentCount7d: cr?.incidentCount7d ?? 0, - disruptionPct: cr?.disruptionPct ?? 0, - riskSummary: cr?.riskSummary ?? '', - riskReportAction: cr?.riskReportAction ?? '', - anomaly, - }; - } - return summaries; -} async function fetchChokepointData(): Promise { const ctx = makeInternalCtx(); @@ -294,22 +258,22 @@ async function fetchChokepointData(): Promise { getCachedJson(FLOWS_KEY, true).catch(() => null) as Promise | null>, ]); - let summaries = transitSummariesData?.summaries ?? {}; + const summaries = transitSummariesData?.summaries ?? {}; + const transitSummariesMissing = Object.keys(summaries).length === 0; - // Fallback: if pre-built summaries are empty, read raw upstream keys directly - if (Object.keys(summaries).length === 0) { - const [portwatch, corridorRisk, transitCounts] = await Promise.all([ - getCachedJson(PORTWATCH_FALLBACK_KEY, true).catch(() => null) as Promise, - getCachedJson(CORRIDORRISK_FALLBACK_KEY, true).catch(() => null) as Promise | null>, - getCachedJson(TRANSIT_COUNTS_FALLBACK_KEY, true).catch(() => null) as Promise, - ]); - if (portwatch && Object.keys(portwatch).length > 0) { - summaries = buildFallbackSummaries(portwatch, corridorRisk, transitCounts, CHOKEPOINTS); - } - } const warnings = navResult.warnings || []; const disruptions: AisDisruption[] = vesselResult.snapshot?.disruptions || []; - const upstreamUnavailable = (navFailed && vesselFailed) || (navFailed && disruptions.length === 0) || (vesselFailed && warnings.length === 0); + + // Treat a missing compact summary as upstream-unavailable so the outer + // cachedFetchJson caches NEG_SENTINEL (120s neg TTL) rather than pinning a + // healthy-but-zero response for the full REDIS_CACHE_TTL (5min). Before this + // gate, a single Redis read timeout silently published 13 zero-state + // chokepoints to supply_chain:chokepoints:v4 and the panel stayed empty + // until that cache expired. See docs/plans/chokepoint-rpc-payload-split.md. + const upstreamUnavailable = transitSummariesMissing + || (navFailed && vesselFailed) + || (navFailed && disruptions.length === 0) + || (vesselFailed && warnings.length === 0); const warningsByChokepoint = groupWarningsByChokepoint(warnings); const disruptionsByChokepoint = groupDisruptionsByChokepoint(disruptions); const threatConfigFresh = isThreatConfigFresh(); @@ -366,7 +330,10 @@ async function fetchChokepointData(): Promise { todayCargo: ts.todayCargo, todayOther: ts.todayOther, wowChangePct: ts.wowChangePct, - history: ts.history, + // History is served separately by GetChokepointHistory (lazy-loaded on + // card expand) — field stays declared for proto compat but is empty + // on the main status response. + history: [], riskLevel: ts.riskLevel, incidentCount7d: ts.incidentCount7d, disruptionPct: ts.disruptionPct, diff --git a/server/worldmonitor/supply-chain/v1/handler.ts b/server/worldmonitor/supply-chain/v1/handler.ts index fab471d85..ed9795e22 100644 --- a/server/worldmonitor/supply-chain/v1/handler.ts +++ b/server/worldmonitor/supply-chain/v1/handler.ts @@ -2,6 +2,7 @@ import type { SupplyChainServiceHandler } from '../../../../src/generated/server import { getShippingRates } from './get-shipping-rates'; import { getChokepointStatus } from './get-chokepoint-status'; +import { getChokepointHistory } from './get-chokepoint-history'; import { getCriticalMinerals } from './get-critical-minerals'; import { getShippingStress } from './get-shipping-stress'; import { getCountryChokepointIndex } from './get-country-chokepoint-index'; @@ -14,6 +15,7 @@ import { getRouteImpact } from './get-route-impact'; export const supplyChainHandler: SupplyChainServiceHandler = { getShippingRates, getChokepointStatus, + getChokepointHistory, getCriticalMinerals, getShippingStress, getCountryChokepointIndex, diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index a4fd40d35..bf5a90ee1 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -12,7 +12,8 @@ import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; import { isMobileDevice, getCSSColor } from '@/utils'; import { TransitChart } from '@/utils/transit-chart'; import { HS2RingChart } from '@/utils/hs2-ring-chart'; -import type { GetChokepointStatusResponse } from '@/services/supply-chain'; +import type { GetChokepointStatusResponse, TransitDayCount } from '@/services/supply-chain'; +import { fetchChokepointHistory } from '@/services/supply-chain'; import { t } from '@/services/i18n'; import { fetchHotspotContext, formatArticleDate, extractDomain, type GdeltArticle } from '@/services/gdelt-intel'; import { getWingbitsLiveFlight } from '@/services/wingbits'; @@ -225,6 +226,10 @@ export class MapPopup { private repairShips: RepairShip[] = []; private chokepointData: GetChokepointStatusResponse | null = null; private transitChart: TransitChart | null = null; + // Session-scoped cache: history is now lazy-loaded via GetChokepointHistory + // when a waterway popup opens (main status RPC omits it to keep payloads small). + private static historyCache = new Map(); + private static historyInflight = new Set(); private isMobileSheet = false; private sheetTouchStartY: number | null = null; private sheetCurrentOffset = 0; @@ -272,12 +277,46 @@ export class MapPopup { c => c.id === waterway.chokepointId, ); const chartEl = this.popup.querySelector('[data-transit-chart]'); - if (chartEl && cp?.transitSummary?.history?.length) { - this.transitChart = new TransitChart(); - this.transitChart.mount(chartEl, cp.transitSummary.history); + const cpId = cp?.id ?? ''; + const isPro = hasPremiumAccess(getAuthState()); + + if (chartEl && cpId && isPro) { + const cached = MapPopup.historyCache.get(cpId); + if (cached && cached.length) { + this.transitChart = new TransitChart(); + this.transitChart.mount(chartEl, cached); + } else if (!MapPopup.historyInflight.has(cpId)) { + // We cache ONLY non-empty successful responses. An empty-array result + // or error is not cached, so re-opening the popup retries. Caching [] + // would poison the chokepoint for the session — empty-array is + // truthy in JS, so `cached && cached.length` is false AND + // `!cached` is also false → neither branch fires, popup stuck on + // "Loading…". The /get-chokepoint-history gateway tier is "slow" + // (5-min CF edge cache) so retries stay cheap. + MapPopup.historyInflight.add(cpId); + void fetchChokepointHistory(cpId).then(resp => { + MapPopup.historyInflight.delete(cpId); + const liveEl = this.popup?.querySelector('[data-transit-chart]'); + if (!liveEl) return; + if (resp.history.length) { + MapPopup.historyCache.set(cpId, resp.history); + liveEl.textContent = ''; + this.transitChart = new TransitChart(); + this.transitChart.mount(liveEl, resp.history); + } else { + liveEl.textContent = t('components.supplyChain.historyUnavailable') || 'History unavailable'; + } + }).catch(() => { + MapPopup.historyInflight.delete(cpId); + const liveEl = this.popup?.querySelector('[data-transit-chart]'); + if (liveEl) liveEl.textContent = t('components.supplyChain.historyUnavailable') || 'History unavailable'; + }); + } } - // Track PRO gate impression for transit chart - if (cp?.transitSummary?.history?.length && !hasPremiumAccess(getAuthState())) { + // Track PRO gate impression for transit chart — we always render the gate + // for non-PRO users on chokepoints (history is a PRO feature); this + // doesn't depend on whether history has resolved. + if (cpId && !isPro) { trackGateHit('chokepoint-transit-chart'); } @@ -1293,7 +1332,10 @@ export class MapPopup { const cp = this.chokepointData?.chokepoints?.find( c => c.id === waterway.chokepointId, ); - const hasChart = !!(cp?.transitSummary?.history?.length); + // Chart is now lazy-loaded via GetChokepointHistory on popup mount. Always + // render the section for any known chokepoint; the initial placeholder + // swaps to a chart (PRO) or "History unavailable" as the fetch resolves. + const hasChart = !!cp; const isPro = hasPremiumAccess(getAuthState()); const sectors = CHOKEPOINT_HS2_SECTORS[waterway.chokepointId]; @@ -1307,7 +1349,7 @@ export class MapPopup { let chartSection = ''; if (hasChart) { if (isPro) { - chartSection = `
`; + chartSection = `
${t('components.supplyChain.loadingHistory') || 'Loading transit history\u2026'}
`; } else { chartSection = `
diff --git a/src/components/SupplyChainPanel.ts b/src/components/SupplyChainPanel.ts index 87b5a348c..26d6b8be0 100644 --- a/src/components/SupplyChainPanel.ts +++ b/src/components/SupplyChainPanel.ts @@ -5,7 +5,8 @@ import type { GetCriticalMineralsResponse, GetShippingStressResponse, } from '@/services/supply-chain'; -import { fetchBypassOptions } from '@/services/supply-chain'; +import { fetchBypassOptions, fetchChokepointHistory } from '@/services/supply-chain'; +import type { TransitDayCount } from '@/services/supply-chain'; import type { ScenarioResult } from '@/config/scenario-templates'; import { SCENARIO_TEMPLATES } from '@/config/scenario-templates'; import { TransitChart } from '@/utils/transit-chart'; @@ -32,6 +33,11 @@ export class SupplyChainPanel extends Panel { private transitChart = new TransitChart(); private chartObserver: MutationObserver | null = null; private chartMountTimer: ReturnType | null = null; + // Session-scoped cache for lazy-loaded transit histories (keyed by chokepoint id). + // Populated on first card expand via fetchChokepointHistory; reused across re-renders + // so we don't refetch 35KB per expand/collapse cycle. + private historyCache = new Map(); + private historyInflight = new Set(); private bypassUnsubscribe: (() => void) | null = null; private bypassGateTracked = false; private onDismissScenario: (() => void) | null = null; @@ -159,9 +165,46 @@ export class SupplyChainPanel extends Panel { const mountTransitChart = (): boolean => { const el = this.content.querySelector(`[data-chart-cp="${expandedCpName}"]`) as HTMLElement | null; if (!el) return false; - if (cp?.transitSummary?.history?.length) { - this.transitChart.mount(el, cp.transitSummary.history); + const cpId = cp?.id ?? ''; + if (!cpId) { el.textContent = t('components.supplyChain.historyUnavailable') || 'History unavailable'; return true; } + + const cached = this.historyCache.get(cpId); + if (cached && cached.length) { + el.removeAttribute('style'); + el.style.marginTop = '8px'; + el.style.minHeight = '200px'; + el.textContent = ''; + this.transitChart.mount(el, cached); + return true; } + + // NOTE: we do NOT cache empty/error results — a transient deploy-window + // miss or a brief Redis error would otherwise poison the chokepoint for + // the entire session. Each re-expand retries; the /get-chokepoint-history + // gateway tier is "slow" (5-min CF edge cache) so retries stay cheap. + + if (this.historyInflight.has(cpId)) return true; + this.historyInflight.add(cpId); + void fetchChokepointHistory(cpId).then(resp => { + this.historyInflight.delete(cpId); + // Still mounted? Re-query — DOM may have re-rendered since fetch started. + const liveEl = this.content.querySelector(`[data-chart-cp-id="${cpId}"]`) as HTMLElement | null; + if (!liveEl) return; + if (resp.history.length) { + this.historyCache.set(cpId, resp.history); + liveEl.removeAttribute('style'); + liveEl.style.marginTop = '8px'; + liveEl.style.minHeight = '200px'; + liveEl.textContent = ''; + this.transitChart.mount(liveEl, resp.history); + } else { + liveEl.textContent = t('components.supplyChain.historyUnavailable') || 'History unavailable'; + } + }).catch(() => { + this.historyInflight.delete(cpId); + const liveEl = this.content.querySelector(`[data-chart-cp-id="${cpId}"]`) as HTMLElement | null; + if (liveEl) liveEl.textContent = t('components.supplyChain.historyUnavailable') || 'History unavailable'; + }); return true; }; @@ -299,8 +342,12 @@ export class SupplyChainPanel extends Panel { const actionRow = expanded && ts?.riskReportAction ? `
${escapeHtml(ts.riskReportAction)}
` : ''; - const chartPlaceholder = expanded && ts?.history?.length - ? `
` + // Always render the chart placeholder when expanded — history is now + // lazy-loaded via GetChokepointHistory RPC (see mountTransitChart below). + // The placeholder shows a loading hint that's swapped to a chart once + // history resolves, or to a graceful "unavailable" message on empty. + const chartPlaceholder = expanded + ? `
${t('components.supplyChain.loadingHistory') || 'Loading transit history\u2026'}
` : ''; const tier = cp.warRiskTier ?? 'WAR_RISK_TIER_NORMAL'; 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 e144b8d06..a05b9fcee 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -102,6 +102,16 @@ export interface FlowEstimate { hazardAlertName: string; } +export interface GetChokepointHistoryRequest { + chokepointId: string; +} + +export interface GetChokepointHistoryResponse { + chokepointId: string; + history: TransitDayCount[]; + fetchedAt: string; +} + export interface GetCriticalMineralsRequest { } @@ -415,6 +425,31 @@ export class SupplyChainServiceClient { return await resp.json() as GetChokepointStatusResponse; } + async getChokepointHistory(req: GetChokepointHistoryRequest, options?: SupplyChainServiceCallOptions): Promise { + let path = "/api/supply-chain/v1/get-chokepoint-history"; + const params = new URLSearchParams(); + if (req.chokepointId != null && req.chokepointId !== "") params.set("chokepointId", String(req.chokepointId)); + 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 GetChokepointHistoryResponse; + } + async getCriticalMinerals(req: GetCriticalMineralsRequest, options?: SupplyChainServiceCallOptions): Promise { let path = "/api/supply-chain/v1/get-critical-minerals"; const url = this.baseURL + path; 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 7901f3d24..fd9efc1bd 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -102,6 +102,16 @@ export interface FlowEstimate { hazardAlertName: string; } +export interface GetChokepointHistoryRequest { + chokepointId: string; +} + +export interface GetChokepointHistoryResponse { + chokepointId: string; + history: TransitDayCount[]; + fetchedAt: string; +} + export interface GetCriticalMineralsRequest { } @@ -368,6 +378,7 @@ export interface RouteDescriptor { export interface SupplyChainServiceHandler { getShippingRates(ctx: ServerContext, req: GetShippingRatesRequest): Promise; getChokepointStatus(ctx: ServerContext, req: GetChokepointStatusRequest): Promise; + getChokepointHistory(ctx: ServerContext, req: GetChokepointHistoryRequest): Promise; getCriticalMinerals(ctx: ServerContext, req: GetCriticalMineralsRequest): Promise; getShippingStress(ctx: ServerContext, req: GetShippingStressRequest): Promise; getCountryChokepointIndex(ctx: ServerContext, req: GetCountryChokepointIndexRequest): Promise; @@ -457,6 +468,53 @@ export function createSupplyChainServiceRoutes( } }, }, + { + method: "GET", + path: "/api/supply-chain/v1/get-chokepoint-history", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: GetChokepointHistoryRequest = { + chokepointId: params.get("chokepointId") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getChokepointHistory", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getChokepointHistory(ctx, body); + return new Response(JSON.stringify(result as GetChokepointHistoryResponse), { + 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" }, + }); + } + }, + }, { method: "GET", path: "/api/supply-chain/v1/get-critical-minerals", diff --git a/src/locales/en.json b/src/locales/en.json index a7771b82f..65d5874ff 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -947,6 +947,8 @@ "corridorDisruption": "Corridor Disruption", "corridor": "Corridor", "loadingCorridors": "Loading corridor data...", + "loadingHistory": "Loading transit history…", + "historyUnavailable": "Transit history unavailable", "mineral": "Mineral", "topProducers": "Top Producers", "risk": "Risk", diff --git a/src/services/supply-chain/index.ts b/src/services/supply-chain/index.ts index f4c26b434..89a24f5c1 100644 --- a/src/services/supply-chain/index.ts +++ b/src/services/supply-chain/index.ts @@ -4,6 +4,7 @@ import { SupplyChainServiceClient, type GetShippingRatesResponse, type GetChokepointStatusResponse, + type GetChokepointHistoryResponse, type GetCriticalMineralsResponse, type GetShippingStressResponse, type GetCountryChokepointIndexResponse, @@ -19,6 +20,7 @@ import { type ShippingRatePoint, type ChokepointExposureEntry, type BypassOption, + type TransitDayCount, } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import { createCircuitBreaker } from '@/utils'; import { getHydratedData } from '@/services/bootstrap'; @@ -26,6 +28,7 @@ import { getHydratedData } from '@/services/bootstrap'; export type { GetShippingRatesResponse, GetChokepointStatusResponse, + GetChokepointHistoryResponse, GetCriticalMineralsResponse, GetShippingStressResponse, GetCountryChokepointIndexResponse, @@ -41,6 +44,7 @@ export type { ShippingRatePoint, ChokepointExposureEntry, BypassOption, + TransitDayCount, }; const client = new SupplyChainServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); @@ -79,6 +83,22 @@ export async function fetchChokepointStatus(): Promise { + try { + return await client.getChokepointHistory({ chokepointId }); + } catch { + return { chokepointId, history: [], fetchedAt: '0' }; + } +} + export async function fetchCriticalMinerals(): Promise { const hydrated = getHydratedData('minerals') as GetCriticalMineralsResponse | undefined; if (hydrated?.minerals?.length) return hydrated; diff --git a/tests/get-chokepoint-history.test.mjs b/tests/get-chokepoint-history.test.mjs new file mode 100644 index 000000000..ae488b4c1 --- /dev/null +++ b/tests/get-chokepoint-history.test.mjs @@ -0,0 +1,86 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const handlerSrc = readFileSync( + resolve(root, 'server/worldmonitor/supply-chain/v1/get-chokepoint-history.ts'), + 'utf-8', +); +const handlerMapSrc = readFileSync( + resolve(root, 'server/worldmonitor/supply-chain/v1/handler.ts'), + 'utf-8', +); + +describe('get-chokepoint-history handler (source analysis)', () => { + it('reads from the per-id history key prefix', () => { + assert.match(handlerSrc, /supply_chain:transit-summaries:history:v1:/); + }); + + it('uses getCachedJson in raw mode (unprefixed key)', () => { + assert.match(handlerSrc, /getCachedJson\(`\$\{HISTORY_KEY_PREFIX\}\$\{id\}`,\s*true\)/); + }); + + it('validates chokepointId against the canonical set', () => { + assert.match(handlerSrc, /CANONICAL_CHOKEPOINTS/); + assert.match(handlerSrc, /VALID_IDS\.has\(id\)/); + }); + + it('returns empty history with fetchedAt=0 on invalid id, missing key, or error', () => { + // Invalid id branch + assert.match(handlerSrc, /!id\s*\|\|\s*!VALID_IDS\.has\(id\)/); + // Missing key / non-array branch + assert.match(handlerSrc, /!payload\s*\|\|\s*!Array\.isArray\(payload\.history\)/); + // Catch block returns empty history (all three paths return fetchedAt '0') + const emptyReturns = [...handlerSrc.matchAll(/fetchedAt:\s*'0'/g)]; + assert.ok(emptyReturns.length >= 3, `expected 3+ fetchedAt:'0' returns, got ${emptyReturns.length}`); + }); + + it('is wired into the SupplyChainService handler map', () => { + assert.match(handlerMapSrc, /import\s+\{\s*getChokepointHistory\s*\}/); + assert.match(handlerMapSrc, /\bgetChokepointHistory,/); + }); +}); + +describe('proto wiring', () => { + const protoSrc = readFileSync( + resolve(root, 'proto/worldmonitor/supply_chain/v1/service.proto'), + 'utf-8', + ); + const historyProto = readFileSync( + resolve(root, 'proto/worldmonitor/supply_chain/v1/get_chokepoint_history.proto'), + 'utf-8', + ); + + it('service.proto imports and registers GetChokepointHistory', () => { + assert.match(protoSrc, /import "worldmonitor\/supply_chain\/v1\/get_chokepoint_history\.proto"/); + assert.match(protoSrc, /rpc GetChokepointHistory\(GetChokepointHistoryRequest\) returns \(GetChokepointHistoryResponse\)/); + assert.match(protoSrc, /path:\s*"\/get-chokepoint-history",\s*method:\s*HTTP_METHOD_GET/); + }); + + it('GetChokepointHistoryRequest requires chokepoint_id as a query param', () => { + assert.match(historyProto, /\(buf\.validate\.field\)\.required\s*=\s*true/); + assert.match(historyProto, /\(sebuf\.http\.query\)\s*=\s*\{name:\s*"chokepointId"\}/); + }); + + it('GetChokepointHistoryResponse carries chokepoint_id, history, fetched_at', () => { + assert.match(historyProto, /string chokepoint_id\s*=\s*1/); + assert.match(historyProto, /repeated TransitDayCount history\s*=\s*2/); + assert.match(historyProto, /int64 fetched_at\s*=\s*3/); + }); +}); + +describe('Redis timeout observability', () => { + const redisSrc = readFileSync(resolve(root, 'server/_shared/redis.ts'), 'utf-8'); + + it('logs [REDIS-TIMEOUT] with key and timeoutMs on AbortError', () => { + // Grepable tag that log drains / Sentry-Vercel integration can pick up — + // before this, large-payload timeouts silently returned null and consumers + // cached zero-state. See docs/plans/chokepoint-rpc-payload-split.md. + assert.match(redisSrc, /isTimeout\s*=\s*err instanceof Error && err\.name === 'AbortError'/); + assert.match(redisSrc, /\[REDIS-TIMEOUT\] getCachedJson key=\$\{key\} timeoutMs=\$\{REDIS_OP_TIMEOUT_MS\}/); + }); +}); diff --git a/tests/supply-chain-panel-transit-chart.test.mjs b/tests/supply-chain-panel-transit-chart.test.mjs index bfebd9124..f3f94a4d0 100644 --- a/tests/supply-chain-panel-transit-chart.test.mjs +++ b/tests/supply-chain-panel-transit-chart.test.mjs @@ -81,19 +81,43 @@ describe('SupplyChainPanel transit chart mount contract', () => { assert.ok(body.includes('disconnect'), 'observer callback must disconnect itself'); }); - it('mountTransitChart checks for chart element and transit history before mounting', () => { - // The mount function should guard against missing DOM elements and missing data + it('mountTransitChart lazy-loads history via fetchChokepointHistory and mounts on resolve', () => { + // After the payload-split: history is NOT part of the main status RPC. + // mountTransitChart must (1) find the chart element by cp name, + // (2) check a session cache, (3) call fetchChokepointHistory on miss, + // (4) mount the chart on the live element when the fetch resolves. assert.ok( panelSrc.includes('querySelector(`[data-chart-cp='), 'must query for chart container element by chokepoint name' ); assert.ok( - panelSrc.includes('transitSummary?.history?.length'), - 'must check transitSummary.history exists before mounting' + panelSrc.includes('fetchChokepointHistory('), + 'must lazy-fetch history via fetchChokepointHistory RPC' + ); + assert.ok( + panelSrc.includes('this.historyCache'), + 'must cache history results for the session (avoid refetch on re-expand)' ); assert.ok( panelSrc.includes('transitChart.mount('), - 'must call transitChart.mount with element and history data' + 'must call transitChart.mount when history resolves' + ); + }); + + it('does NOT cache empty/error results — session-sticky regression guard', () => { + // Caching [] or on error would poison the chokepoint for the whole + // session (transient miss → never retries). Only cache on non-empty + // success. Empty/error show the "unavailable" placeholder but leave + // the cache untouched so the next re-expand retries. + assert.ok( + !panelSrc.match(/historyCache\.set\([^,]+,\s*\[\]\)/), + 'panel must NOT cache empty arrays' + ); + // The success branch gates the set() on resp.history.length — match the + // conditional-set pattern inside the .then() block. + assert.ok( + /if\s*\(resp\.history\.length\)\s*\{[\s\S]*?historyCache\.set\(/.test(panelSrc), + 'panel must only cache on resp.history.length > 0' ); }); diff --git a/tests/transit-summaries.test.mjs b/tests/transit-summaries.test.mjs index b37d604d5..ef3f7a2f9 100644 --- a/tests/transit-summaries.test.mjs +++ b/tests/transit-summaries.test.mjs @@ -45,19 +45,54 @@ describe('seedTransitSummaries (relay)', () => { assert.match(relaySrc, /seed-meta:supply_chain:transit-summaries/); }); - it('summary object includes all required fields', () => { + it('compact summary object includes all stat fields (history split out)', () => { assert.match(relaySrc, /todayTotal:/); assert.match(relaySrc, /todayTanker:/); assert.match(relaySrc, /todayCargo:/); assert.match(relaySrc, /todayOther:/); assert.match(relaySrc, /wowChangePct:/); - assert.match(relaySrc, /history:/); assert.match(relaySrc, /riskLevel:/); assert.match(relaySrc, /incidentCount7d:/); assert.match(relaySrc, /disruptionPct:/); assert.match(relaySrc, /anomaly/); }); + it('compact summary object does NOT inline history (payload-split guard)', () => { + // Matches the `summaries[cpId] = { ... }` block specifically — history + // belongs to the per-id key now, not the compact summary. + const block = relaySrc.match(/summaries\[cpId\]\s*=\s*\{([\s\S]*?)\};/); + assert.ok(block, 'compact summary assignment not found'); + assert.doesNotMatch(block[1], /\bhistory:/); + }); + + it('writes per-id history keys via envelopeWrite', () => { + assert.match(relaySrc, /TRANSIT_SUMMARY_HISTORY_KEY_PREFIX/); + assert.match(relaySrc, /supply_chain:transit-summaries:history:v1:/); + // Per-id payload includes chokepointId, history, fetchedAt + assert.match(relaySrc, /chokepointId:\s*cpId,\s*history,\s*fetchedAt:\s*now/); + }); + + it('iterates the canonical chokepoint ID set (not Object.entries(pw))', () => { + // Partial-coverage regression guard: iterating over whatever pw carries + // silently drops missing chokepoints. RPC sees a partial summaries shape + // and caches zero-state rows for 5 min since upstreamUnavailable only + // fires on fully-empty. Writer must emit all 13 canonical IDs with + // zero-state fill for missing upstream data. + assert.match(relaySrc, /CANONICAL_IDS\s*=\s*Object\.keys\(CHOKEPOINT_THREAT_LEVELS\)/); + assert.match(relaySrc, /for\s*\(const cpId of CANONICAL_IDS\)/); + assert.doesNotMatch(relaySrc, /for\s*\(const \[cpId, cpData\] of Object\.entries\(pw\)\)/); + }); + + it('records actual upstream coverage (pwCovered) in seed-meta + envelope', () => { + // seed-meta recordCount must reflect pwCovered, not the always-13 canonical + // shape size — otherwise health.js can't distinguish healthy 13/13 from + // partial-upstream 10/13. + assert.match(relaySrc, /let\s+pwCovered\s*=\s*0/); + assert.match(relaySrc, /if\s*\(cpData\)\s*pwCovered\+\+/); + assert.match(relaySrc, /recordCount:\s*pwCovered/); + assert.match(relaySrc, /coverage shortfall/); + }); + it('reads latestCorridorRiskData for riskLevel/incidentCount7d/disruptionPct', () => { assert.match(relaySrc, /latestCorridorRiskData\?\.\[cpId\]/); assert.match(relaySrc, /cr\?\.riskLevel/); @@ -66,12 +101,19 @@ describe('seedTransitSummaries (relay)', () => { }); it('reads pw from Redis for history and wowChangePct', () => { - assert.match(relaySrc, /cpData\.history/); - assert.match(relaySrc, /cpData\.wowChangePct/); + // After canonical-coverage refactor, cpData is nullable (missing upstream), + // so access is `cpData?.history` / `cpData?.wowChangePct` with zero-state + // fallback for missing IDs. + assert.match(relaySrc, /cpData\?\.history/); + assert.match(relaySrc, /cpData\?\.wowChangePct/); }); - it('calls detectTrafficAnomalyRelay with history and threat level', () => { - assert.match(relaySrc, /detectTrafficAnomalyRelay\(cpData\.history,\s*threatLevel\)/); + it('calls detectTrafficAnomalyRelay with local history binding', () => { + // history is bound from `cpData?.history ?? []` before the anomaly call, + // so detectTrafficAnomalyRelay runs on a concrete array even when the + // canonical chokepoint is missing from this cycle's portwatch payload. + assert.match(relaySrc, /const history = cpData\?\.history \?\? \[\]/); + assert.match(relaySrc, /detectTrafficAnomalyRelay\(history,\s*threatLevel\)/); }); it('wraps summaries in { summaries, fetchedAt } envelope', () => { @@ -234,16 +276,13 @@ describe('get-chokepoint-status handler (source analysis)', () => { assert.match(handlerSrc, /getCachedJson\(TRANSIT_SUMMARIES_KEY/); }); - it('imports PortWatchData for fallback assembly', () => { - assert.match(handlerSrc, /import.*PortWatchData/); - }); - - it('does NOT import CorridorRiskData (uses local interface)', () => { - assert.doesNotMatch(handlerSrc, /import.*CorridorRiskData/); - }); - - it('imports CANONICAL_CHOKEPOINTS for fallback relay-name mapping', () => { - assert.match(handlerSrc, /import.*CANONICAL_CHOKEPOINTS/); + it('does NOT import PortWatchData or CANONICAL_CHOKEPOINTS (fallback path removed)', () => { + // Fallback against raw 500KB portwatch/corridorrisk keys was removed — + // the compact transit-summaries key is authoritative; missing key now + // surfaces as upstreamUnavailable=true rather than triggering a large + // secondary read that times out at the 1.5s Redis budget. + assert.doesNotMatch(handlerSrc, /import.*PortWatchData/); + assert.doesNotMatch(handlerSrc, /import\s*\{\s*CANONICAL_CHOKEPOINTS\s*\}/); }); it('does NOT import portwatchNameToId or corridorRiskNameToId', () => { @@ -251,6 +290,22 @@ describe('get-chokepoint-status handler (source analysis)', () => { assert.doesNotMatch(handlerSrc, /import.*corridorRiskNameToId/); }); + it('treats missing transit-summaries as upstreamUnavailable (silent-cache regression guard)', () => { + // Regression guard for the silent zero-state cache bug: before this fix, + // a null transit-summaries read produced 13 zero-state chokepoints that + // were cached for 5 min (REDIS_CACHE_TTL). Now we mark upstreamUnavailable + // so cachedFetchJson writes NEG_SENTINEL (120s) and retries on next poll. + assert.match(handlerSrc, /transitSummariesMissing/); + assert.match(handlerSrc, /const upstreamUnavailable\s*=\s*transitSummariesMissing/); + }); + + it('omits history from the transit summary response (lazy-loaded via GetChokepointHistory)', () => { + // Main status response no longer carries 180-day history per chokepoint — + // clients lazy-fetch via GetChokepointHistory on card expand. Field stays + // declared for proto compat but is always empty in this RPC. + assert.match(handlerSrc, /history:\s*\[\],\s*\n\s*riskLevel:\s*ts\.riskLevel/); + }); + it('defines PreBuiltTransitSummary interface with all required fields', () => { assert.match(handlerSrc, /interface PreBuiltTransitSummary/); assert.match(handlerSrc, /todayTotal:\s*number/); @@ -474,26 +529,20 @@ describe('CHOKEPOINT_THREAT_LEVELS relay-handler sync', () => { }); // --------------------------------------------------------------------------- -// 8. Handler reads pre-built summaries first, falls back to raw keys +// 8. Handler reads ONLY the compact transit-summaries key (no fallback) // --------------------------------------------------------------------------- describe('handler transit data strategy', () => { - it('reads TRANSIT_SUMMARIES_KEY as primary source', () => { + it('reads TRANSIT_SUMMARIES_KEY as the only transit source', () => { assert.match(handlerSrc, /TRANSIT_SUMMARIES_KEY/); }); - it('has fallback keys for portwatch, corridorrisk, and transit counts', () => { - assert.match(handlerSrc, /PORTWATCH_FALLBACK_KEY/); - assert.match(handlerSrc, /CORRIDORRISK_FALLBACK_KEY/); - assert.match(handlerSrc, /TRANSIT_COUNTS_FALLBACK_KEY/); - }); - - it('fallback triggers only when pre-built summaries are empty', () => { - assert.match(handlerSrc, /Object\.keys\(summaries\)\.length === 0/); - }); - - it('fallback builds summaries with detectTrafficAnomaly', () => { - assert.match(handlerSrc, /buildFallbackSummaries/); - assert.match(handlerSrc, /detectTrafficAnomaly/); + it('does NOT reference removed fallback keys (portwatch / corridorrisk / chokepoint_transits)', () => { + // Previously each of these was a ~500KB secondary read that stacked on + // top of the 1.5s Redis read budget and timed out. Removed in payload-split PR. + assert.doesNotMatch(handlerSrc, /PORTWATCH_FALLBACK_KEY/); + assert.doesNotMatch(handlerSrc, /CORRIDORRISK_FALLBACK_KEY/); + assert.doesNotMatch(handlerSrc, /TRANSIT_COUNTS_FALLBACK_KEY/); + assert.doesNotMatch(handlerSrc, /buildFallbackSummaries/); }); it('does NOT call getPortWatchTransits or fetchCorridorRisk (no upstream fetch)', () => {