diff --git a/docs/api/MaritimeService.openapi.json b/docs/api/MaritimeService.openapi.json index ae1de46fa..e91e550b2 100644 --- a/docs/api/MaritimeService.openapi.json +++ b/docs/api/MaritimeService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AisDensityZone":{"description":"AisDensityZone represents a zone of concentrated vessel traffic.","properties":{"deltaPct":{"description":"Change from baseline as a percentage.","format":"double","type":"number"},"id":{"description":"Zone identifier.","minLength":1,"type":"string"},"intensity":{"description":"Traffic intensity score (0-100).","format":"double","maximum":100,"minimum":0,"type":"number"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Zone name (e.g., \"Strait of Malacca\").","type":"string"},"note":{"description":"Analyst note.","type":"string"},"shipsPerDay":{"description":"Estimated ships per day.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisDisruption":{"description":"AisDisruption represents a detected anomaly in AIS vessel tracking data.","properties":{"changePct":{"description":"Percentage change from normal.","format":"double","type":"number"},"darkShips":{"description":"Number of dark ships (AIS off) detected.","format":"int32","type":"integer"},"description":{"description":"Human-readable description.","type":"string"},"id":{"description":"Disruption identifier.","minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name.","type":"string"},"region":{"description":"Region name.","type":"string"},"severity":{"description":"AisDisruptionSeverity represents the severity of an AIS disruption.","enum":["AIS_DISRUPTION_SEVERITY_UNSPECIFIED","AIS_DISRUPTION_SEVERITY_LOW","AIS_DISRUPTION_SEVERITY_ELEVATED","AIS_DISRUPTION_SEVERITY_HIGH"],"type":"string"},"type":{"description":"AisDisruptionType represents the type of AIS tracking anomaly.\n Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.","enum":["AIS_DISRUPTION_TYPE_UNSPECIFIED","AIS_DISRUPTION_TYPE_GAP_SPIKE","AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION"],"type":"string"},"vesselCount":{"description":"Number of vessels in the affected area.","format":"int32","type":"integer"},"windowHours":{"description":"Analysis window in hours.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisSnapshotStatus":{"description":"AisSnapshotStatus reports relay health at the time of the snapshot.","properties":{"connected":{"description":"Whether the relay WebSocket is connected to the AIS provider.","type":"boolean"},"messages":{"description":"Total AIS messages processed in the current session.","format":"int32","type":"integer"},"vessels":{"description":"Number of vessels currently tracked by the relay.","format":"int32","type":"integer"}},"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"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetVesselSnapshotRequest":{"description":"GetVesselSnapshotRequest specifies filters for the vessel snapshot.","properties":{"includeCandidates":{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","type":"boolean"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"GetVesselSnapshotResponse":{"description":"GetVesselSnapshotResponse contains the vessel traffic snapshot.","properties":{"snapshot":{"$ref":"#/components/schemas/VesselSnapshot"}},"type":"object"},"ListNavigationalWarningsRequest":{"description":"ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.","properties":{"area":{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListNavigationalWarningsResponse":{"description":"ListNavigationalWarningsResponse contains navigational warnings matching the request.","properties":{"pagination":{"$ref":"#/components/schemas/PaginationResponse"},"warnings":{"items":{"$ref":"#/components/schemas/NavigationalWarning"},"type":"array"}},"type":"object"},"NavigationalWarning":{"description":"NavigationalWarning represents a maritime safety warning from NGA.","properties":{"area":{"description":"Geographic area affected.","type":"string"},"authority":{"description":"Warning source authority.","type":"string"},"expiresAt":{"description":"Warning expiry date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Warning identifier.","type":"string"},"issuedAt":{"description":"Warning issue date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"text":{"description":"Full warning text.","type":"string"},"title":{"description":"Warning title.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"SnapshotCandidateReport":{"description":"SnapshotCandidateReport is a per-vessel position report attached to a\n snapshot. Used to drive the client-side position callback system.","properties":{"course":{"description":"Course over ground in degrees.","format":"int32","type":"integer"},"heading":{"description":"Heading in degrees (0-359, or 511 for unavailable).","format":"int32","type":"integer"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"mmsi":{"description":"Maritime Mobile Service Identity.","type":"string"},"name":{"description":"Vessel name (may be empty if unknown).","type":"string"},"shipType":{"description":"AIS ship type code (0 if unknown).","format":"int32","type":"integer"},"speed":{"description":"Speed over ground in knots.","format":"double","type":"number"},"timestamp":{"description":"Report timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"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"},"VesselSnapshot":{"description":"VesselSnapshot represents a point-in-time view of civilian AIS vessel data.","properties":{"candidateReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"},"densityZones":{"items":{"$ref":"#/components/schemas/AisDensityZone"},"type":"array"},"disruptions":{"items":{"$ref":"#/components/schemas/AisDisruption"},"type":"array"},"sequence":{"description":"Monotonic sequence number from the relay. Clients use this to detect stale\n responses during polling.","format":"int32","type":"integer"},"snapshotAt":{"description":"Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"status":{"$ref":"#/components/schemas/AisSnapshotStatus"}},"type":"object"}}},"info":{"title":"MaritimeService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/maritime/v1/get-vessel-snapshot":{"get":{"description":"GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.","operationId":"GetVesselSnapshot","parameters":[{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","in":"query","name":"include_candidates","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVesselSnapshotResponse"}}},"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":"GetVesselSnapshot","tags":["MaritimeService"]}},"/api/maritime/v1/list-navigational-warnings":{"get":{"description":"ListNavigationalWarnings retrieves active maritime safety warnings from NGA.","operationId":"ListNavigationalWarnings","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","in":"query","name":"area","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNavigationalWarningsResponse"}}},"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":"ListNavigationalWarnings","tags":["MaritimeService"]}}}} \ No newline at end of file +{"components":{"schemas":{"AisDensityZone":{"description":"AisDensityZone represents a zone of concentrated vessel traffic.","properties":{"deltaPct":{"description":"Change from baseline as a percentage.","format":"double","type":"number"},"id":{"description":"Zone identifier.","minLength":1,"type":"string"},"intensity":{"description":"Traffic intensity score (0-100).","format":"double","maximum":100,"minimum":0,"type":"number"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Zone name (e.g., \"Strait of Malacca\").","type":"string"},"note":{"description":"Analyst note.","type":"string"},"shipsPerDay":{"description":"Estimated ships per day.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisDisruption":{"description":"AisDisruption represents a detected anomaly in AIS vessel tracking data.","properties":{"changePct":{"description":"Percentage change from normal.","format":"double","type":"number"},"darkShips":{"description":"Number of dark ships (AIS off) detected.","format":"int32","type":"integer"},"description":{"description":"Human-readable description.","type":"string"},"id":{"description":"Disruption identifier.","minLength":1,"type":"string"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"description":"Descriptive name.","type":"string"},"region":{"description":"Region name.","type":"string"},"severity":{"description":"AisDisruptionSeverity represents the severity of an AIS disruption.","enum":["AIS_DISRUPTION_SEVERITY_UNSPECIFIED","AIS_DISRUPTION_SEVERITY_LOW","AIS_DISRUPTION_SEVERITY_ELEVATED","AIS_DISRUPTION_SEVERITY_HIGH"],"type":"string"},"type":{"description":"AisDisruptionType represents the type of AIS tracking anomaly.\n Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.","enum":["AIS_DISRUPTION_TYPE_UNSPECIFIED","AIS_DISRUPTION_TYPE_GAP_SPIKE","AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION"],"type":"string"},"vesselCount":{"description":"Number of vessels in the affected area.","format":"int32","type":"integer"},"windowHours":{"description":"Analysis window in hours.","format":"int32","type":"integer"}},"required":["id"],"type":"object"},"AisSnapshotStatus":{"description":"AisSnapshotStatus reports relay health at the time of the snapshot.","properties":{"connected":{"description":"Whether the relay WebSocket is connected to the AIS provider.","type":"boolean"},"messages":{"description":"Total AIS messages processed in the current session.","format":"int32","type":"integer"},"vessels":{"description":"Number of vessels currently tracked by the relay.","format":"int32","type":"integer"}},"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"},"GeoCoordinates":{"description":"GeoCoordinates represents a geographic location using WGS84 coordinates.","properties":{"latitude":{"description":"Latitude in decimal degrees (-90 to 90).","format":"double","maximum":90,"minimum":-90,"type":"number"},"longitude":{"description":"Longitude in decimal degrees (-180 to 180).","format":"double","maximum":180,"minimum":-180,"type":"number"}},"type":"object"},"GetVesselSnapshotRequest":{"description":"GetVesselSnapshotRequest specifies filters for the vessel snapshot.","properties":{"includeCandidates":{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","type":"boolean"},"includeTankers":{"description":"When true, populate VesselSnapshot.tanker_reports with per-vessel\n position reports for AIS ship-type 80-89 (tanker class). Used by the\n Energy Atlas live-tanker map layer. Stored separately from\n candidate_reports (which is military-only) so consumers self-select\n via this flag rather than the response field changing meaning.","type":"boolean"},"neLat":{"description":"North-east corner latitude of bounding box.","format":"double","type":"number"},"neLon":{"description":"North-east corner longitude of bounding box.","format":"double","type":"number"},"swLat":{"description":"South-west corner latitude of bounding box.","format":"double","type":"number"},"swLon":{"description":"South-west corner longitude of bounding box.","format":"double","type":"number"}},"type":"object"},"GetVesselSnapshotResponse":{"description":"GetVesselSnapshotResponse contains the vessel traffic snapshot.","properties":{"snapshot":{"$ref":"#/components/schemas/VesselSnapshot"}},"type":"object"},"ListNavigationalWarningsRequest":{"description":"ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.","properties":{"area":{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"}},"type":"object"},"ListNavigationalWarningsResponse":{"description":"ListNavigationalWarningsResponse contains navigational warnings matching the request.","properties":{"pagination":{"$ref":"#/components/schemas/PaginationResponse"},"warnings":{"items":{"$ref":"#/components/schemas/NavigationalWarning"},"type":"array"}},"type":"object"},"NavigationalWarning":{"description":"NavigationalWarning represents a maritime safety warning from NGA.","properties":{"area":{"description":"Geographic area affected.","type":"string"},"authority":{"description":"Warning source authority.","type":"string"},"expiresAt":{"description":"Warning expiry date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Warning identifier.","type":"string"},"issuedAt":{"description":"Warning issue date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"location":{"$ref":"#/components/schemas/GeoCoordinates"},"text":{"description":"Full warning text.","type":"string"},"title":{"description":"Warning title.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"SnapshotCandidateReport":{"description":"SnapshotCandidateReport is a per-vessel position report attached to a\n snapshot. Used to drive the client-side position callback system.","properties":{"course":{"description":"Course over ground in degrees.","format":"int32","type":"integer"},"heading":{"description":"Heading in degrees (0-359, or 511 for unavailable).","format":"int32","type":"integer"},"lat":{"description":"Latitude in decimal degrees.","format":"double","type":"number"},"lon":{"description":"Longitude in decimal degrees.","format":"double","type":"number"},"mmsi":{"description":"Maritime Mobile Service Identity.","type":"string"},"name":{"description":"Vessel name (may be empty if unknown).","type":"string"},"shipType":{"description":"AIS ship type code (0 if unknown).","format":"int32","type":"integer"},"speed":{"description":"Speed over ground in knots.","format":"double","type":"number"},"timestamp":{"description":"Report timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"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"},"VesselSnapshot":{"description":"VesselSnapshot represents a point-in-time view of civilian AIS vessel data.","properties":{"candidateReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"},"densityZones":{"items":{"$ref":"#/components/schemas/AisDensityZone"},"type":"array"},"disruptions":{"items":{"$ref":"#/components/schemas/AisDisruption"},"type":"array"},"sequence":{"description":"Monotonic sequence number from the relay. Clients use this to detect stale\n responses during polling.","format":"int32","type":"integer"},"snapshotAt":{"description":"Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"status":{"$ref":"#/components/schemas/AisSnapshotStatus"},"tankerReports":{"items":{"$ref":"#/components/schemas/SnapshotCandidateReport"},"type":"array"}},"type":"object"}}},"info":{"title":"MaritimeService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/maritime/v1/get-vessel-snapshot":{"get":{"description":"GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.","operationId":"GetVesselSnapshot","parameters":[{"description":"North-east corner latitude of bounding box.","in":"query","name":"ne_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"North-east corner longitude of bounding box.","in":"query","name":"ne_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner latitude of bounding box.","in":"query","name":"sw_lat","required":false,"schema":{"format":"double","type":"number"}},{"description":"South-west corner longitude of bounding box.","in":"query","name":"sw_lon","required":false,"schema":{"format":"double","type":"number"}},{"description":"When true, populate VesselSnapshot.candidate_reports with per-vessel\n position reports. Clients with no position callbacks should leave this\n false to keep responses small.","in":"query","name":"include_candidates","required":false,"schema":{"type":"boolean"}},{"description":"When true, populate VesselSnapshot.tanker_reports with per-vessel\n position reports for AIS ship-type 80-89 (tanker class). Used by the\n Energy Atlas live-tanker map layer. Stored separately from\n candidate_reports (which is military-only) so consumers self-select\n via this flag rather than the response field changing meaning.","in":"query","name":"include_tankers","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetVesselSnapshotResponse"}}},"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":"GetVesselSnapshot","tags":["MaritimeService"]}},"/api/maritime/v1/list-navigational-warnings":{"get":{"description":"ListNavigationalWarnings retrieves active maritime safety warnings from NGA.","operationId":"ListNavigationalWarnings","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional area filter (e.g., \"NAVAREA IV\", \"Persian Gulf\").","in":"query","name":"area","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNavigationalWarningsResponse"}}},"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":"ListNavigationalWarnings","tags":["MaritimeService"]}}}} \ No newline at end of file diff --git a/docs/api/MaritimeService.openapi.yaml b/docs/api/MaritimeService.openapi.yaml index 1e44db3a8..35a69b989 100644 --- a/docs/api/MaritimeService.openapi.yaml +++ b/docs/api/MaritimeService.openapi.yaml @@ -48,6 +48,17 @@ paths: required: false schema: type: boolean + - name: include_tankers + in: query + description: |- + When true, populate VesselSnapshot.tanker_reports with per-vessel + position reports for AIS ship-type 80-89 (tanker class). Used by the + Energy Atlas live-tanker map layer. Stored separately from + candidate_reports (which is military-only) so consumers self-select + via this flag rather than the response field changing meaning. + required: false + schema: + type: boolean responses: "200": description: Successful response @@ -171,6 +182,14 @@ components: When true, populate VesselSnapshot.candidate_reports with per-vessel position reports. Clients with no position callbacks should leave this false to keep responses small. + includeTankers: + type: boolean + description: |- + When true, populate VesselSnapshot.tanker_reports with per-vessel + position reports for AIS ship-type 80-89 (tanker class). Used by the + Energy Atlas live-tanker map layer. Stored separately from + candidate_reports (which is military-only) so consumers self-select + via this flag rather than the response field changing meaning. description: GetVesselSnapshotRequest specifies filters for the vessel snapshot. GetVesselSnapshotResponse: type: object @@ -205,6 +224,10 @@ components: type: array items: $ref: '#/components/schemas/SnapshotCandidateReport' + tankerReports: + type: array + items: + $ref: '#/components/schemas/SnapshotCandidateReport' description: VesselSnapshot represents a point-in-time view of civilian AIS vessel data. AisDensityZone: type: object diff --git a/docs/api/worldmonitor.openapi.yaml b/docs/api/worldmonitor.openapi.yaml index 85cbbeb8b..51c16c25d 100644 --- a/docs/api/worldmonitor.openapi.yaml +++ b/docs/api/worldmonitor.openapi.yaml @@ -3977,6 +3977,17 @@ paths: required: false schema: type: boolean + - name: include_tankers + in: query + description: |- + When true, populate VesselSnapshot.tanker_reports with per-vessel + position reports for AIS ship-type 80-89 (tanker class). Used by the + Energy Atlas live-tanker map layer. Stored separately from + candidate_reports (which is military-only) so consumers self-select + via this flag rather than the response field changing meaning. + required: false + schema: + type: boolean responses: "200": description: Successful response @@ -14743,6 +14754,14 @@ components: When true, populate VesselSnapshot.candidate_reports with per-vessel position reports. Clients with no position callbacks should leave this false to keep responses small. + includeTankers: + type: boolean + description: |- + When true, populate VesselSnapshot.tanker_reports with per-vessel + position reports for AIS ship-type 80-89 (tanker class). Used by the + Energy Atlas live-tanker map layer. Stored separately from + candidate_reports (which is military-only) so consumers self-select + via this flag rather than the response field changing meaning. description: GetVesselSnapshotRequest specifies filters for the vessel snapshot. worldmonitor_maritime_v1_GetVesselSnapshotResponse: type: object @@ -14777,6 +14796,10 @@ components: type: array items: $ref: '#/components/schemas/worldmonitor_maritime_v1_SnapshotCandidateReport' + tankerReports: + type: array + items: + $ref: '#/components/schemas/worldmonitor_maritime_v1_SnapshotCandidateReport' description: VesselSnapshot represents a point-in-time view of civilian AIS vessel data. worldmonitor_maritime_v1_AisDensityZone: type: object diff --git a/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto index 2441613ff..bd10eecac 100644 --- a/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto +++ b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto @@ -19,6 +19,12 @@ message GetVesselSnapshotRequest { // position reports. Clients with no position callbacks should leave this // false to keep responses small. bool include_candidates = 5 [(sebuf.http.query) = { name: "include_candidates" }]; + // When true, populate VesselSnapshot.tanker_reports with per-vessel + // position reports for AIS ship-type 80-89 (tanker class). Used by the + // Energy Atlas live-tanker map layer. Stored separately from + // candidate_reports (which is military-only) so consumers self-select + // via this flag rather than the response field changing meaning. + bool include_tankers = 6 [(sebuf.http.query) = { name: "include_tankers" }]; } // GetVesselSnapshotResponse contains the vessel traffic snapshot. diff --git a/proto/worldmonitor/maritime/v1/vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto index 44b64545b..0c62043da 100644 --- a/proto/worldmonitor/maritime/v1/vessel_snapshot.proto +++ b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto @@ -22,6 +22,12 @@ message VesselSnapshot { // Recent position reports for individual vessels. Only populated when the // request sets include_candidates=true — empty otherwise. repeated SnapshotCandidateReport candidate_reports = 6; + // Recent position reports for tanker vessels (AIS ship type 80-89). Only + // populated when the request sets include_tankers=true — empty otherwise. + // Reuses the SnapshotCandidateReport message shape; the field is parallel + // to candidate_reports (military-detection) so adding tanker-rendering + // doesn't change the meaning of the existing surface. + repeated SnapshotCandidateReport tanker_reports = 7; } // AisSnapshotStatus reports relay health at the time of the snapshot. diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index ebd3d8583..b7fa5dacc 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -6365,6 +6365,12 @@ const vessels = new Map(); const vesselHistory = new Map(); const densityGrid = new Map(); const candidateReports = new Map(); +// Parallel store for tanker (AIS ship type 80-89) position reports — populated +// alongside candidateReports but with a different inclusion predicate. +// Required by the Energy Atlas live-tanker map layer (parity-push PR 3). +// Kept SEPARATE from candidateReports so the existing military-detection +// consumer's contract is unchanged. +const tankerReports = new Map(); let snapshotSequence = 0; let lastSnapshot = null; @@ -6643,6 +6649,26 @@ function processPositionReportForSnapshot(data) { timestamp: now, }); } + + // Tanker capture for the Energy Atlas live-tanker layer. AIS ship type + // 80-89 covers all tanker subtypes per ITU-R M.1371 (oil/chemical tanker, + // hazardous cargo classes A-D, and other tanker variants). Stored in a + // SEPARATE Map from candidateReports so the existing military-detection + // consumer never sees tankers (their contract is unchanged). + const shipType = Number(meta.ShipType); + if (Number.isFinite(shipType) && shipType >= 80 && shipType <= 89) { + tankerReports.set(mmsi, { + mmsi, + name: meta.ShipName || '', + lat, + lon, + shipType, + heading: pos.TrueHeading, + speed: pos.Sog, + course: pos.Cog, + timestamp: now, + }); + } } function cleanupAggregates() { @@ -6701,6 +6727,18 @@ function cleanupAggregates() { // Hard cap: keep freshest candidate reports. evictMapByTimestamp(candidateReports, MAX_CANDIDATE_REPORTS, (report) => report.timestamp || 0); + // Tanker reports: same retention window as candidate reports — a vessel + // that hasn't broadcast a position in CANDIDATE_RETENTION_MS is no longer + // useful for a live-tanker map layer. Cap at 2× the per-response cap so + // we have headroom for bbox filtering to find recent fixes anywhere on + // the globe (not just one chokepoint). + for (const [mmsi, report] of tankerReports) { + if (report.timestamp < now - CANDIDATE_RETENTION_MS) { + tankerReports.delete(mmsi); + } + } + evictMapByTimestamp(tankerReports, MAX_TANKER_REPORTS_PER_RESPONSE * 10, (report) => report.timestamp || 0); + // Clean chokepoint buckets: remove stale vessels for (const [cpName, bucket] of chokepointBuckets) { for (const mmsi of bucket) { @@ -6844,6 +6882,55 @@ function getCandidateReportsSnapshot() { .slice(0, MAX_CANDIDATE_REPORTS); } +// Server-side cap for tanker_reports per request — protects the response +// payload from a misbehaving filter that returns thousands of vessels. +// 200/zone × 6 chokepoints in worst case is well under any practical +// CDN/edge payload budget. Energy Atlas live-tanker layer also caps +// client-side on top of this. +const MAX_TANKER_REPORTS_PER_RESPONSE = 200; + +/** + * Parse a "bbox" query param of the form "swLat,swLon,neLat,neLon" into a + * {sw: {lat, lon}, ne: {lat, lon}} or null if absent / malformed. + * + * Validates: + * - 4 comma-separated finite numbers + * - sw <= ne (after normalization) + * - bbox size ≤ 10° on both lat and lon (10° max per parity-push plan U7; + * prevents pulling every vessel through one query) + * + * @param {string | null | undefined} raw + * @returns {{ sw: {lat:number, lon:number}, ne: {lat:number, lon:number} } | null} + */ +function parseBbox(raw) { + if (!raw) return null; + const parts = String(raw).split(',').map(Number); + if (parts.length !== 4 || parts.some((v) => !Number.isFinite(v))) return null; + const [swLat, swLon, neLat, neLon] = parts; + if (swLat > neLat || swLon > neLon) return null; + if (swLat < -90 || neLat > 90 || swLon < -180 || neLon > 180) return null; + if (neLat - swLat > 10 || neLon - swLon > 10) return null; // 10° guard + return { sw: { lat: swLat, lon: swLon }, ne: { lat: neLat, lon: neLon } }; +} + +/** + * Filtered + capped tanker reports. Sorted by recency of last fix so the + * 200-cap keeps the most-recently-seen vessels rather than a random subset. + * + * @param {{ sw: {lat:number,lon:number}, ne: {lat:number,lon:number} } | null} bbox + */ +function getTankerReportsSnapshot(bbox) { + let arr = Array.from(tankerReports.values()); + if (bbox) { + arr = arr.filter( + (r) => r.lat >= bbox.sw.lat && r.lat <= bbox.ne.lat && + r.lon >= bbox.sw.lon && r.lon <= bbox.ne.lon, + ); + } + arr.sort((a, b) => b.timestamp - a.timestamp); + return arr.slice(0, MAX_TANKER_REPORTS_PER_RESPONSE); +} + function buildSnapshot() { const now = Date.now(); if (lastSnapshot && now - lastSnapshotAt < Math.floor(SNAPSHOT_INTERVAL_MS / 2)) { @@ -8791,19 +8878,40 @@ const server = http.createServer(async (req, res) => { buildSnapshot(); // ensures cache is warm const url = new URL(req.url, `http://localhost:${PORT}`); const includeCandidates = url.searchParams.get('candidates') === 'true'; - const json = includeCandidates ? lastSnapshotWithCandJson : lastSnapshotJson; - const gz = includeCandidates ? lastSnapshotWithCandGzip : lastSnapshotGzip; - const br = includeCandidates ? lastSnapshotWithCandBrotli : lastSnapshotBrotli; + const includeTankers = url.searchParams.get('tankers') === 'true'; + const bbox = parseBbox(url.searchParams.get('bbox')); - if (json) { - sendPreGzipped(req, res, 200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=2', - 'CDN-Cache-Control': 'public, max-age=10', - }, json, gz, br); + // Fast path: pre-gzipped cache covers the {with|without}-candidates + // case only (no tankers, no bbox). Used by the existing AIS density + + // military-detection consumers, which are the vast majority of traffic. + if (!includeTankers && !bbox) { + const json = includeCandidates ? lastSnapshotWithCandJson : lastSnapshotJson; + const gz = includeCandidates ? lastSnapshotWithCandGzip : lastSnapshotGzip; + const br = includeCandidates ? lastSnapshotWithCandBrotli : lastSnapshotBrotli; + if (json) { + sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=2', + 'CDN-Cache-Control': 'public, max-age=10', + }, json, gz, br); + } else { + const payload = { ...lastSnapshot, candidateReports: includeCandidates ? getCandidateReportsSnapshot() : [], tankerReports: [] }; + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=2', + 'CDN-Cache-Control': 'public, max-age=10', + }, JSON.stringify(payload)); + } } else { - // Cold start fallback - const payload = { ...lastSnapshot, candidateReports: includeCandidates ? getCandidateReportsSnapshot() : [] }; + // Live-tanker path: bbox-filtered + tanker-included responses skip the + // pre-gzipped cache (bbox space would explode the cache key set). + // Handler-side 60s cache (server/worldmonitor/maritime/v1/get-vessel-snapshot.ts) + // and the gateway 'live' tier absorb identical-bbox requests. + const payload = { + ...lastSnapshot, + candidateReports: includeCandidates ? getCandidateReportsSnapshot() : [], + tankerReports: includeTankers ? getTankerReportsSnapshot(bbox) : [], + }; sendCompressed(req, res, 200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=2', diff --git a/server/_shared/rate-limit.ts b/server/_shared/rate-limit.ts index a96fa5688..1acd1c7a3 100644 --- a/server/_shared/rate-limit.ts +++ b/server/_shared/rate-limit.ts @@ -96,6 +96,10 @@ export const ENDPOINT_RATE_POLICIES: Record = { // inline Upstash INCR. Gateway now enforces the same budget with per-IP // keying in checkEndpointRateLimit. '/api/scenario/v1/run-scenario': { limit: 10, window: '60 s' }, + // Live tanker map (Energy Atlas): one user with 6 chokepoints × 1 call/min + // = 6 req/min/IP base load. 60/min headroom covers tab refreshes + zoom + // pans within a single user without flagging legitimate traffic. + '/api/maritime/v1/get-vessel-snapshot': { limit: 60, window: '60 s' }, }; const endpointLimiters = new Map(); diff --git a/server/gateway.ts b/server/gateway.ts index c593e4a31..45f566adc 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -26,11 +26,16 @@ export const serverOptions: ServerOptions = { onError: mapErrorToResponse }; // NOTE: This map is shared across all domain bundles (~3KB). Kept centralised for // single-source-of-truth maintainability; the size is negligible vs handler code. -type CacheTier = 'fast' | 'medium' | 'slow' | 'slow-browser' | 'static' | 'daily' | 'no-store'; +type CacheTier = 'fast' | 'medium' | 'slow' | 'slow-browser' | 'static' | 'daily' | 'no-store' | 'live'; // Three-tier caching: browser (max-age) → CF edge (s-maxage) → Vercel CDN (CDN-Cache-Control). // CF ignores Vary: Origin so it may pin a single ACAO value, but this is acceptable // since production traffic is same-origin and preview deployments hit Vercel CDN directly. +// +// 'live' tier (60s) is for endpoints with strict freshness contracts — the +// energy-atlas live-tanker map layer requires position fixes to refresh on +// the order of one minute. Every shorter-than-medium tier is custom; we keep +// the existing tiers untouched so unrelated endpoints aren't impacted. const TIER_HEADERS: Record = { fast: 'public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600', medium: 'public, max-age=120, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', @@ -39,6 +44,7 @@ const TIER_HEADERS: Record = { static: 'public, max-age=600, s-maxage=3600, stale-while-revalidate=600, stale-if-error=14400', daily: 'public, max-age=3600, s-maxage=14400, stale-while-revalidate=7200, stale-if-error=172800', 'no-store': 'no-store', + live: 'public, max-age=30, s-maxage=60, stale-while-revalidate=60, stale-if-error=300', }; // Vercel CDN-specific cache TTLs — CDN-Cache-Control overrides Cache-Control for @@ -52,10 +58,14 @@ const TIER_CDN_CACHE: Record = { static: 'public, s-maxage=14400, stale-while-revalidate=3600, stale-if-error=28800', daily: 'public, s-maxage=86400, stale-while-revalidate=14400, stale-if-error=172800', 'no-store': null, + live: 'public, s-maxage=60, stale-while-revalidate=60, stale-if-error=300', }; const RPC_CACHE_TIER: Record = { - '/api/maritime/v1/get-vessel-snapshot': 'no-store', + // 'live' tier — bbox-quantized + tanker-aware caching upstream of the + // 60s in-handler cache, absorbing identical-bbox requests at the CDN + // before they hit this Vercel function. Energy Atlas live-tanker layer. + '/api/maritime/v1/get-vessel-snapshot': 'live', '/api/market/v1/list-market-quotes': 'medium', '/api/market/v1/list-crypto-quotes': 'medium', diff --git a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts index e9f7e4a97..3246f0ba6 100644 --- a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts +++ b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts @@ -27,10 +27,27 @@ const SEVERITY_MAP: Record = { high: 'AIS_DISRUPTION_SEVERITY_HIGH', }; -// Cache the two variants separately — candidate reports materially change -// payload size, and clients with no position callbacks should not have to -// wait on or pay for the heavier payload. -const SNAPSHOT_CACHE_TTL_MS = 300_000; // 5 min -- matches client poll interval +// In-process cache TTLs. +// +// The base snapshot (no candidates, no tankers, no bbox) is the high-traffic +// path consumed by the AIS-density layer + military-detection consumers. It +// re-uses the existing 5-minute cache because density / disruptions only +// change once per relay cycle. +// +// Tanker (live-tanker map layer) and bbox-filtered responses MUST refresh +// every 60s to honor the live-tanker freshness contract — anything longer +// shows stale vessel positions and collapses distinct bboxes onto one +// payload, defeating the bbox parameter entirely. +const SNAPSHOT_CACHE_TTL_BASE_MS = 300_000; // 5 min for non-bbox / non-tanker reads +const SNAPSHOT_CACHE_TTL_LIVE_MS = 60_000; // 60 s for live tanker / bbox reads + +// 1° bbox quantization for cache-key reuse: a user panning a few decimal +// degrees should hit the same cache slot as another user nearby. Done +// server-side so the gateway 'live' tier sees identical query strings and +// the CDN absorbs the request before it reaches this handler. +function quantize(v: number): number { + return Math.floor(v); +} interface SnapshotCacheSlot { snapshot: VesselSnapshot | undefined; @@ -38,28 +55,91 @@ interface SnapshotCacheSlot { inFlight: Promise | null; } -const cache: Record<'with' | 'without', SnapshotCacheSlot> = { - with: { snapshot: undefined, timestamp: 0, inFlight: null }, - without: { snapshot: undefined, timestamp: 0, inFlight: null }, -}; +// Cache keyed by request shape: candidates, tankers, and quantized bbox. +// Replaces the prior `with|without` keying which would silently serve +// stale tanker data and collapse distinct bboxes. +// +// LRU-bounded: each distinct (includeCandidates, includeTankers, quantizedBbox) +// triple creates a slot. With 1° quantization and a misbehaving client, the +// keyspace is ~64,000 (180×360); without a cap the Map would grow unbounded +// across the lifetime of the serverless instance. Realistic load is ~12 slots +// (6 chokepoints × 2 flag combos), so a 128-slot cap leaves >10x headroom for +// edge panning while making OOM impossible. +const SNAPSHOT_CACHE_MAX_SLOTS = 128; +const cache = new Map(); -async function fetchVesselSnapshot(includeCandidates: boolean): Promise { - const slot = cache[includeCandidates ? 'with' : 'without']; +function touchSlot(key: string, slot: SnapshotCacheSlot): void { + // Move to end of insertion order so it's most-recently-used. Map iteration + // order = insertion order, so the first entry is the LRU candidate. + cache.delete(key); + cache.set(key, slot); +} + +function evictIfNeeded(): void { + if (cache.size < SNAPSHOT_CACHE_MAX_SLOTS) return; + // Walk insertion order; evict the first slot that has no in-flight fetch. + // An in-flight slot is still in use by an awaiting caller — evicting it + // would orphan the promise. + for (const [k, s] of cache) { + if (s.inFlight === null) { + cache.delete(k); + return; + } + } + // All slots in flight — nothing to evict. Caller still inserts; we + // accept temporary growth past the cap until in-flight settles. +} + +function cacheKeyFor( + includeCandidates: boolean, + includeTankers: boolean, + bbox: { swLat: number; swLon: number; neLat: number; neLon: number } | null, +): string { + const c = includeCandidates ? '1' : '0'; + const t = includeTankers ? '1' : '0'; + if (!bbox) return `${c}${t}|null`; + const sl = quantize(bbox.swLat); + const so = quantize(bbox.swLon); + const nl = quantize(bbox.neLat); + const no = quantize(bbox.neLon); + return `${c}${t}|${sl},${so},${nl},${no}`; +} + +function ttlFor(includeTankers: boolean, bbox: unknown): number { + return includeTankers || bbox ? SNAPSHOT_CACHE_TTL_LIVE_MS : SNAPSHOT_CACHE_TTL_BASE_MS; +} + +async function fetchVesselSnapshot( + includeCandidates: boolean, + includeTankers: boolean, + bbox: { swLat: number; swLon: number; neLat: number; neLon: number } | null, +): Promise { + const key = cacheKeyFor(includeCandidates, includeTankers, bbox); + let slot = cache.get(key); + if (!slot) { + evictIfNeeded(); + slot = { snapshot: undefined, timestamp: 0, inFlight: null }; + cache.set(key, slot); + } const now = Date.now(); - if (slot.snapshot && (now - slot.timestamp) < SNAPSHOT_CACHE_TTL_MS) { + const ttl = ttlFor(includeTankers, bbox); + if (slot.snapshot && (now - slot.timestamp) < ttl) { + touchSlot(key, slot); return slot.snapshot; } if (slot.inFlight) { + touchSlot(key, slot); return slot.inFlight; } - slot.inFlight = fetchVesselSnapshotFromRelay(includeCandidates); + slot.inFlight = fetchVesselSnapshotFromRelay(includeCandidates, includeTankers, bbox); try { const result = await slot.inFlight; if (result) { slot.snapshot = result; slot.timestamp = Date.now(); + touchSlot(key, slot); } return result ?? slot.snapshot; // serve stale on relay failure } finally { @@ -87,13 +167,31 @@ function toCandidateReport(raw: any): SnapshotCandidateReport | null { }; } -async function fetchVesselSnapshotFromRelay(includeCandidates: boolean): Promise { +async function fetchVesselSnapshotFromRelay( + includeCandidates: boolean, + includeTankers: boolean, + bbox: { swLat: number; swLon: number; neLat: number; neLon: number } | null, +): Promise { try { const relayBaseUrl = getRelayBaseUrl(); if (!relayBaseUrl) return undefined; + const params = new URLSearchParams(); + params.set('candidates', includeCandidates ? 'true' : 'false'); + if (includeTankers) params.set('tankers', 'true'); + if (bbox) { + // Quantized bbox: prevents the relay from caching one URL per + // floating-point pixel as users pan. Same quantization as the + // handler-side cache key so they stay consistent. + const sl = quantize(bbox.swLat); + const so = quantize(bbox.swLon); + const nl = quantize(bbox.neLat); + const no = quantize(bbox.neLon); + params.set('bbox', `${sl},${so},${nl},${no}`); + } + const response = await fetch( - `${relayBaseUrl}/ais/snapshot?candidates=${includeCandidates ? 'true' : 'false'}`, + `${relayBaseUrl}/ais/snapshot?${params.toString()}`, { headers: getRelayHeaders(), signal: AbortSignal.timeout(10000), @@ -141,6 +239,9 @@ async function fetchVesselSnapshotFromRelay(includeCandidates: boolean): Promise const candidateReports = (includeCandidates && Array.isArray(data.candidateReports)) ? data.candidateReports.map(toCandidateReport).filter((r: SnapshotCandidateReport | null): r is SnapshotCandidateReport => r !== null) : []; + const tankerReports = (includeTankers && Array.isArray(data.tankerReports)) + ? data.tankerReports.map(toCandidateReport).filter((r: SnapshotCandidateReport | null): r is SnapshotCandidateReport => r !== null) + : []; return { snapshotAt: Date.now(), @@ -153,6 +254,7 @@ async function fetchVesselSnapshotFromRelay(includeCandidates: boolean): Promise messages: Number.isFinite(Number(rawStatus.messages)) ? Number(rawStatus.messages) : 0, }, candidateReports, + tankerReports, }; } catch { return undefined; @@ -163,14 +265,79 @@ async function fetchVesselSnapshotFromRelay(includeCandidates: boolean): Promise // RPC handler // ======================================================================== +// Bbox-size guard: reject requests where either dimension exceeds 10°. This +// prevents a malicious or buggy client from requesting a global box and +// pulling every tanker through one query. +const MAX_BBOX_DEGREES = 10; + +/** + * 400-class bbox validation error. Carries `statusCode = 400` so + * server/error-mapper.ts surfaces it as HTTP 400 (the mapper branches + * on `'statusCode' in error`; a plain Error would fall through to + * "unhandled error" → 500). Used for both the size guard and the + * lat/lon range guard. + * + * Range checks matter because the relay silently DROPS a malformed + * bbox param and serves a global capped tanker subset — making the + * layer appear to "work" with stale data instead of failing loudly. + */ +export class BboxValidationError extends Error { + readonly statusCode = 400; + constructor(reason: string) { + super(`bbox invalid: ${reason}`); + this.name = 'BboxValidationError'; + } +} + +// Backwards-compatible alias for tests that imported BboxTooLargeError. +// Prefer BboxValidationError for new code. +export const BboxTooLargeError = BboxValidationError; + +function isValidLatLon(lat: number, lon: number): boolean { + return ( + Number.isFinite(lat) && Number.isFinite(lon) && + lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 + ); +} + +function extractAndValidateBbox(req: GetVesselSnapshotRequest): { swLat: number; swLon: number; neLat: number; neLon: number } | null { + const sw = { lat: Number(req.swLat), lon: Number(req.swLon) }; + const ne = { lat: Number(req.neLat), lon: Number(req.neLon) }; + // All zeroes (the default for unset proto doubles) → no bbox. + if (sw.lat === 0 && sw.lon === 0 && ne.lat === 0 && ne.lon === 0) { + return null; + } + if (!isValidLatLon(sw.lat, sw.lon)) { + throw new BboxValidationError('sw corner outside lat/lon domain (-90..90 / -180..180)'); + } + if (!isValidLatLon(ne.lat, ne.lon)) { + throw new BboxValidationError('ne corner outside lat/lon domain (-90..90 / -180..180)'); + } + if (sw.lat > ne.lat || sw.lon > ne.lon) { + throw new BboxValidationError('sw corner must be south-west of ne corner'); + } + if (ne.lat - sw.lat > MAX_BBOX_DEGREES || ne.lon - sw.lon > MAX_BBOX_DEGREES) { + throw new BboxValidationError(`each dimension must be ≤ ${MAX_BBOX_DEGREES} degrees`); + } + return { swLat: sw.lat, swLon: sw.lon, neLat: ne.lat, neLon: ne.lon }; +} + export async function getVesselSnapshot( _ctx: ServerContext, req: GetVesselSnapshotRequest, ): Promise { try { - const snapshot = await fetchVesselSnapshot(Boolean(req.includeCandidates)); + const bbox = extractAndValidateBbox(req); + const snapshot = await fetchVesselSnapshot( + Boolean(req.includeCandidates), + Boolean(req.includeTankers), + bbox, + ); return { snapshot }; - } catch { + } catch (err) { + // BboxValidationError carries statusCode=400; rethrowing lets the + // gateway error-mapper surface it as HTTP 400 with the reason string. + if (err instanceof BboxValidationError) throw err; return { snapshot: undefined }; } } diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts index eaf7e6d71..0443748b6 100644 --- a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -257,7 +257,14 @@ async function fetchChokepointData(): Promise { const [navResult, vesselResult, transitSummariesData, flowsData] = await Promise.all([ listNavigationalWarnings(ctx, { area: '', pageSize: 0, cursor: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }), - getVesselSnapshot(ctx, { neLat: 90, neLon: 180, swLat: -90, swLon: -180, includeCandidates: false }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), + // All-zero bbox = "no filter, full snapshot" per the new bbox extractor + // in get-vessel-snapshot.ts. Previously this passed (-90, -180, 90, 180) + // because the handler ignored bbox entirely; the new 10° max-bbox guard + // (added for the live-tanker contract) would reject that range. This + // call doesn't need bbox filtering — it wants the global density + + // disruption surface — so pass zeros and skip both candidate and tanker + // payload tiers. + getVesselSnapshot(ctx, { neLat: 0, neLon: 0, swLat: 0, swLon: 0, includeCandidates: false, includeTankers: false }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), getCachedJson(TRANSIT_SUMMARIES_KEY, true).catch(() => null) as Promise, getCachedJson(FLOWS_KEY, true).catch(() => null) as Promise | null>, ]); diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 8785ec18b..c003133df 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -433,6 +433,9 @@ export class DeckGLMap { private iranEvents: IranEvent[] = []; private aisDisruptions: AisDisruptionEvent[] = []; private aisDensity: AisDensityZone[] = []; + private liveTankers: Array<{ mmsi: string; lat: number; lon: number; speed: number; shipType: number; name: string }> = []; + private liveTankersAbort: AbortController | null = null; + private liveTankersTimer: ReturnType | null = null; private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; private healthByCableId: Record = {}; @@ -1542,6 +1545,28 @@ export class DeckGLMap { this.layerCache.delete('fuel-shortages-layer'); } + // Live tanker positions inside chokepoint bounding boxes. AIS ship type + // 80-89 (tanker class). Refreshed every 60s; one Map + // fetch per layer-tick. deckGLOnly per src/config/map-layer-definitions.ts. + // Powered by the relay's tankerReports field (added in PR 3 U7 alongside + // the existing military-only candidateReports). Energy Atlas parity-push. + if (mapLayers.liveTankers) { + // Start (or keep) the refresh loop while the layer is on. The + // ensure helper handles the "first time on" kick + the 60s + // setInterval; idempotent so calling it on every layers update is + // safe. Render immediately if we already have data; the interval + // re-renders when fresh data arrives. + this.ensureLiveTankersLoop(); + if (this.liveTankers.length > 0) { + layers.push(this.createLiveTankersLayer()); + } + } else { + // Layer toggled off → tear down the timer so we stop hitting the + // relay even when the map is still on screen. + this.stopLiveTankersLoop(); + this.layerCache.delete('live-tankers-layer'); + } + // Conflict zones layer if (mapLayers.conflicts) { layers.push(this.createConflictZonesLayer()); @@ -2954,6 +2979,105 @@ export class DeckGLMap { }); } + private createLiveTankersLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'live-tankers-layer', + data: this.liveTankers, + getPosition: (d) => [d.lon, d.lat], + // Radius scales loosely with deadweight class: VLCC > Aframax > Handysize. + // AIS ship type 80-89 covers all tanker subtypes; we have no DWT field + // in the AIS message itself, so this is a constant fallback. Future + // enhancement: enrich via a vessel-registry lookup. + getRadius: 2500, + getFillColor: (d) => { + // Anchored (speed < 0.5 kn) — orange, signals waiting / loading / + // potential congestion. Underway (speed >= 0.5 kn) — cyan, normal + // transit. Unknown / missing speed — gray. + if (!Number.isFinite(d.speed)) return [127, 140, 141, 200] as [number, number, number, number]; + if (d.speed < 0.5) return [255, 183, 3, 220] as [number, number, number, number]; // amber + return [0, 209, 255, 220] as [number, number, number, number]; // cyan + }, + radiusMinPixels: 3, + radiusMaxPixels: 8, + pickable: true, + }); + } + + /** + * Idempotent: ensures the 60s tanker-refresh loop is running. Called + * each time the layer is observed enabled in the layers update. First + * call kicks an immediate load; subsequent calls no-op. Pairs with + * stopLiveTankersLoop() in destroy() and on layer-disable. + */ + private ensureLiveTankersLoop(): void { + if (this.liveTankersTimer !== null) return; // already running + void this.loadLiveTankers(); + this.liveTankersTimer = setInterval(() => { + void this.loadLiveTankers(); + }, 60_000); + } + + /** + * Stop the refresh loop and abort any in-flight fetch. Called when the + * layer is toggled off (and from destroy()) to keep the relay traffic + * scoped to active viewers. + */ + private stopLiveTankersLoop(): void { + if (this.liveTankersTimer !== null) { + clearInterval(this.liveTankersTimer); + this.liveTankersTimer = null; + } + if (this.liveTankersAbort) { + this.liveTankersAbort.abort(); + this.liveTankersAbort = null; + } + } + + /** + * Tanker loader — called externally (or on a 60s tick) to refresh + * `this.liveTankers`. Imports lazily so the service module isn't pulled + * into the bundle for variants where the layer is disabled. + */ + public async loadLiveTankers(): Promise { + // Cancel any in-flight tick before starting another. Per skill + // closure-scoped-state-teardown-order: don't null out the abort + // controller before calling abort. + if (this.liveTankersAbort) { + this.liveTankersAbort.abort(); + } + const controller = new AbortController(); + this.liveTankersAbort = controller; + try { + const { fetchLiveTankers } = await import('@/services/live-tankers'); + // Thread the signal so the in-flight RPC actually cancels when a + // newer tick starts (or the layer toggles off). Without this, a + // slow older refresh can race-write stale data after a newer one + // already populated this.liveTankers. + const zones = await fetchLiveTankers(undefined, { signal: controller.signal }); + // Drop the result if this controller was aborted mid-flight or if + // a newer load has already replaced us. Without this guard, an + // older fetch that completed despite signal.aborted (e.g. the + // service returned cached data without checking the signal) would + // overwrite the newer one's data. + if (controller.signal.aborted || this.liveTankersAbort !== controller) { + return; + } + const flat = zones.flatMap((z) => z.tankers).map((t) => ({ + mmsi: t.mmsi, + lat: t.lat, + lon: t.lon, + speed: t.speed, + shipType: t.shipType, + name: t.name, + })); + this.liveTankers = flat; + this.updateLayers(); + } catch { + // Graceful: leave existing tankers in place; layer will continue + // rendering last-known data until the next successful tick. + } + } + private createGpsJammingLayer(): H3HexagonLayer { return new H3HexagonLayer({ id: 'gps-jamming-layer', @@ -7003,6 +7127,7 @@ export class DeckGLMap { clearInterval(this.aircraftFetchTimer); this.aircraftFetchTimer = null; } + this.stopLiveTankersLoop(); this.layerCache.clear(); diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index 8c03c02d2..32792edc4 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -103,6 +103,7 @@ export const LAYER_REGISTRY: Record = { // without `deckGLOnly` once both renderers gain real support. storageFacilities: def('storageFacilities', '🏗', 'storageFacilities', 'Storage Facilities', ['flat'], undefined, true), fuelShortages: def('fuelShortages', '⚙', 'fuelShortages', 'Fuel Shortages', ['flat'], undefined, true), + liveTankers: def('liveTankers', '🚢', 'liveTankers', 'Live Tanker Positions', ['flat'], undefined, true), }; const VARIANT_LAYER_ORDER: Record> = { @@ -141,7 +142,7 @@ const VARIANT_LAYER_ORDER: Record> = { energy: [ // Core energy infrastructure — mirror of ENERGY_MAP_LAYERS in panels.ts 'pipelines', 'storageFacilities', 'fuelShortages', 'waterways', 'commodityPorts', 'commodityHubs', - 'ais', 'tradeRoutes', 'minerals', + 'ais', 'liveTankers', 'tradeRoutes', 'minerals', // Energy-adjacent context 'sanctions', 'fires', 'climate', 'weather', 'outages', 'natural', 'resilienceScore', 'dayNight', diff --git a/src/config/panels.ts b/src/config/panels.ts index 84e1b8d75..17b9b1178 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -1023,6 +1023,7 @@ const ENERGY_MAP_LAYERS: MapLayers = { diseaseOutbreaks: false, storageFacilities: true, // UGS / SPR / LNG / crude hubs (Day 9-10 registry) fuelShortages: true, // Global fuel shortage alerts (Day 11-12 registry) + liveTankers: true, // AIS ship type 80-89 inside chokepoint bboxes (parity-push PR 3) }; const ENERGY_MOBILE_MAP_LAYERS: MapLayers = { @@ -1080,6 +1081,7 @@ const ENERGY_MOBILE_MAP_LAYERS: MapLayers = { diseaseOutbreaks: false, storageFacilities: true, fuelShortages: true, + liveTankers: true, }; // ============================================ diff --git a/src/generated/client/worldmonitor/maritime/v1/service_client.ts b/src/generated/client/worldmonitor/maritime/v1/service_client.ts index 2dc3447a7..bbf6fc2c1 100644 --- a/src/generated/client/worldmonitor/maritime/v1/service_client.ts +++ b/src/generated/client/worldmonitor/maritime/v1/service_client.ts @@ -7,6 +7,7 @@ export interface GetVesselSnapshotRequest { swLat: number; swLon: number; includeCandidates: boolean; + includeTankers: boolean; } export interface GetVesselSnapshotResponse { @@ -20,6 +21,7 @@ export interface VesselSnapshot { sequence: number; status?: AisSnapshotStatus; candidateReports: SnapshotCandidateReport[]; + tankerReports: SnapshotCandidateReport[]; } export interface AisDensityZone { @@ -156,6 +158,7 @@ export class MaritimeServiceClient { if (req.swLat != null && req.swLat !== 0) params.set("sw_lat", String(req.swLat)); if (req.swLon != null && req.swLon !== 0) params.set("sw_lon", String(req.swLon)); if (req.includeCandidates) params.set("include_candidates", String(req.includeCandidates)); + if (req.includeTankers) params.set("include_tankers", String(req.includeTankers)); const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); const headers: Record = { diff --git a/src/generated/server/worldmonitor/maritime/v1/service_server.ts b/src/generated/server/worldmonitor/maritime/v1/service_server.ts index 676b63d93..584a18c2f 100644 --- a/src/generated/server/worldmonitor/maritime/v1/service_server.ts +++ b/src/generated/server/worldmonitor/maritime/v1/service_server.ts @@ -7,6 +7,7 @@ export interface GetVesselSnapshotRequest { swLat: number; swLon: number; includeCandidates: boolean; + includeTankers: boolean; } export interface GetVesselSnapshotResponse { @@ -20,6 +21,7 @@ export interface VesselSnapshot { sequence: number; status?: AisSnapshotStatus; candidateReports: SnapshotCandidateReport[]; + tankerReports: SnapshotCandidateReport[]; } export interface AisDensityZone { @@ -168,6 +170,7 @@ export function createMaritimeServiceRoutes( swLat: Number(params.get("sw_lat") ?? "0"), swLon: Number(params.get("sw_lon") ?? "0"), includeCandidates: params.get("include_candidates") === "true", + includeTankers: params.get("include_tankers") === "true", }; if (options?.validateRequest) { const bodyViolations = options.validateRequest("getVesselSnapshot", body); diff --git a/src/services/live-tankers.ts b/src/services/live-tankers.ts new file mode 100644 index 000000000..d22cc19a0 --- /dev/null +++ b/src/services/live-tankers.ts @@ -0,0 +1,158 @@ +// Live Tankers service — fetches per-vessel position reports for AIS ship +// type 80-89 (tanker class) inside chokepoint bounding boxes. Powers the +// LiveTankersLayer on the Energy Atlas map. +// +// Per the parity-push plan U8 (docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md): +// - Sources bbox centroids from `src/config/chokepoint-registry.ts` +// (NOT `server/.../_chokepoint-ids.ts` — that file strips lat/lon). +// - One getVesselSnapshot call per chokepoint, ±2° box around centroid. +// - In-memory cache, 60s TTL per chokepoint key. +// - On per-zone failure, returns last successful response (graceful +// degradation; one outage doesn't blank the whole layer). +// +// The handler-side cache (server/worldmonitor/maritime/v1/get-vessel-snapshot.ts) +// also caches by quantized bbox + tankers flag at 60s TTL, and the gateway +// 'live' tier (server/gateway.ts) sets s-maxage=60 so concurrent identical +// requests across users get absorbed at the CDN. This three-layer cache +// (CDN → handler → service) means the per-tab 6-call/min worst case scales +// sub-linearly with the user count. + +import { CHOKEPOINT_REGISTRY, type ChokepointRegistryEntry } from '@/config/chokepoint-registry'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { MaritimeServiceClient } from '@/generated/client/worldmonitor/maritime/v1/service_client'; +import type { SnapshotCandidateReport } from '@/generated/client/worldmonitor/maritime/v1/service_client'; + +const client = new MaritimeServiceClient(getRpcBaseUrl(), { + fetch: (...args: Parameters) => globalThis.fetch(...args), +}); + +// ±2° box around each chokepoint centroid. Tuned in the implementation +// section of plan U8 — Hormuz traffic at peak transit is ~50-150 vessels +// in this box, well below the server-side 200/zone cap. Implementer should +// adjust if a specific zone (e.g. Malacca, much busier) consistently fills +// the cap. +const BBOX_HALF_DEGREES = 2; + +// Cache TTL must match the gateway 'live' tier's s-maxage (60s). Going +// shorter wastes CDN cache hits; going longer breaks the freshness contract. +const CACHE_TTL_MS = 60_000; + +// Default chokepoints whose live tankers we render. Energy-relevant subset +// of the full chokepoint registry — global trade hubs that aren't oil/gas +// chokepoints (e.g. Strait of Dover, English Channel) are skipped. +const DEFAULT_CHOKEPOINT_IDS = new Set([ + 'hormuz_strait', + 'suez', + 'bab_el_mandeb', + 'malacca_strait', + 'panama', + 'bosphorus', // Turkish Straits per CHOKEPOINT_REGISTRY canonical id +]); + +interface CacheSlot { + data: SnapshotCandidateReport[]; + fetchedAt: number; +} + +const cache = new Map(); + +export interface ChokepointTankers { + chokepoint: ChokepointRegistryEntry; + tankers: SnapshotCandidateReport[]; + /** True when this zone's last fetch failed and we're serving stale data. */ + stale: boolean; +} + +function getDefaultChokepoints(): ChokepointRegistryEntry[] { + return CHOKEPOINT_REGISTRY.filter((c) => DEFAULT_CHOKEPOINT_IDS.has(c.id)); +} + +function bboxFor(c: ChokepointRegistryEntry): { + swLat: number; swLon: number; neLat: number; neLon: number; +} { + return { + swLat: c.lat - BBOX_HALF_DEGREES, + swLon: c.lon - BBOX_HALF_DEGREES, + neLat: c.lat + BBOX_HALF_DEGREES, + neLon: c.lon + BBOX_HALF_DEGREES, + }; +} + +async function fetchOne(c: ChokepointRegistryEntry, signal?: AbortSignal): Promise { + const bbox = bboxFor(c); + const resp = await client.getVesselSnapshot( + { + ...bbox, + includeCandidates: false, + includeTankers: true, + }, + { signal }, + ); + return resp.snapshot?.tankerReports ?? []; +} + +/** + * Fetch tanker positions for a set of chokepoints, returning per-zone + * results. Failed zones return their last successful data with `stale: true`; + * if a zone has never succeeded, it's omitted from the return value. + * + * @param chokepoints - chokepoints to query. Defaults to the energy-relevant + * subset (Hormuz, Suez, Bab el-Mandeb, Malacca, Panama, + * Turkish Straits) when omitted. + * @param options.signal - AbortSignal to cancel in-flight RPC calls when + * the caller's context tears down (layer toggled off, + * map destroyed, newer refresh started). Without this, + * a slow older refresh can race-write stale data after + * a newer one already populated the layer state. + */ +export async function fetchLiveTankers( + chokepoints?: ChokepointRegistryEntry[], + options: { signal?: AbortSignal } = {}, +): Promise { + const targets = chokepoints ?? getDefaultChokepoints(); + const now = Date.now(); + const { signal } = options; + + const results = await Promise.allSettled( + targets.map(async (c) => { + const slot = cache.get(c.id); + if (slot && now - slot.fetchedAt < CACHE_TTL_MS) { + return { chokepoint: c, tankers: slot.data, stale: false }; + } + // Bail early if already aborted before the per-zone fetch starts — + // saves a wasted RPC + cache write when the caller has moved on. + if (signal?.aborted) { + if (slot) return { chokepoint: c, tankers: slot.data, stale: true }; + throw new DOMException('aborted before fetch', 'AbortError'); + } + try { + const tankers = await fetchOne(c, signal); + // Re-check abort after the fetch resolves: prevents a slow + // resolver from clobbering cache after the caller cancelled. + if (signal?.aborted) { + if (slot) return { chokepoint: c, tankers: slot.data, stale: true }; + throw new DOMException('aborted after fetch', 'AbortError'); + } + cache.set(c.id, { data: tankers, fetchedAt: now }); + return { chokepoint: c, tankers, stale: false }; + } catch (err) { + // Per-zone failure: serve last-known data if any. The layer + // continues rendering even if one chokepoint's relay is flaky. + if (slot) return { chokepoint: c, tankers: slot.data, stale: true }; + throw err; // no last-known data → drop this zone + } + }), + ); + + return results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); +} + +// Internal exports for test coverage; not part of the public surface. +export const _internal = { + bboxFor, + getDefaultChokepoints, + CACHE_TTL_MS, + BBOX_HALF_DEGREES, +}; diff --git a/src/services/maritime/index.ts b/src/services/maritime/index.ts index 4eccc6a23..236785a8d 100644 --- a/src/services/maritime/index.ts +++ b/src/services/maritime/index.ts @@ -161,7 +161,7 @@ interface ParsedSnapshot { async function fetchSnapshotPayload(includeCandidates: boolean, signal?: AbortSignal): Promise { const response = await snapshotBreaker.execute( async () => client.getVesselSnapshot( - { neLat: 0, neLon: 0, swLat: 0, swLon: 0, includeCandidates }, + { neLat: 0, neLon: 0, swLat: 0, swLon: 0, includeCandidates, includeTankers: false }, { signal }, ), emptySnapshotFallback, diff --git a/src/types/index.ts b/src/types/index.ts index 32dc20a54..baad6a57b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -680,6 +680,9 @@ export interface MapLayers { // across all other variants remain valid without touching them). storageFacilities?: boolean; fuelShortages?: boolean; + /** Live tanker positions (AIS ship type 80-89) inside chokepoint bboxes. + * Refreshed every 60s via getVesselSnapshot. Energy Atlas parity-push. */ + liveTankers?: boolean; } export interface AIDataCenter { diff --git a/tests/live-tankers-service.test.mts b/tests/live-tankers-service.test.mts new file mode 100644 index 000000000..58189d33b --- /dev/null +++ b/tests/live-tankers-service.test.mts @@ -0,0 +1,98 @@ +// @ts-check +// +// Tests for src/services/live-tankers.ts — the chokepoint-bbox tanker fetch +// helper. We test the pure helpers (bbox derivation, default-chokepoint +// filter, cache-TTL constant) since the network-fetching path needs the +// running getVesselSnapshot RPC + relay to exercise meaningfully. +// +// The real Promise.allSettled + caching behavior is more naturally +// exercised by the existing E2E browser smoke test once the layer is live; +// these tests pin the surface that doesn't require network. + +import { strict as assert } from 'node:assert'; +import { test, describe } from 'node:test'; +import { _internal } from '../src/services/live-tankers.ts'; + +const { bboxFor, getDefaultChokepoints, BBOX_HALF_DEGREES, CACHE_TTL_MS } = _internal; + +describe('live-tankers — defaults', () => { + test('default chokepoint set is the energy-relevant 6', () => { + const ids = getDefaultChokepoints().map((c) => c.id).sort(); + assert.deepEqual(ids, [ + 'bab_el_mandeb', + 'bosphorus', + 'hormuz_strait', + 'malacca_strait', + 'panama', + 'suez', + ]); + }); + + test('cache TTL matches the gateway live-tier s-maxage (60s)', () => { + // If these drift apart, the CDN cache will serve stale data while the + // service-level cache is still warm — confusing. Pin both at 60_000ms. + assert.equal(CACHE_TTL_MS, 60_000); + }); + + test('bbox half-width is ±2 degrees', () => { + assert.equal(BBOX_HALF_DEGREES, 2); + }); +}); + +describe('live-tankers — AbortSignal behavior', () => { + test('fetchLiveTankers accepts an options.signal parameter', async () => { + // Pin the signature so future edits can't accidentally drop the signal + // parameter and silently re-introduce the race-write bug Codex flagged + // on PR #3402: a slow older refresh overwriting a newer one because + // the abort controller wasn't actually wired into the fetch. + const { fetchLiveTankers } = await import('../src/services/live-tankers.ts'); + const controller = new AbortController(); + controller.abort(); // pre-aborted + const result = await fetchLiveTankers([], { signal: controller.signal }); + assert.deepEqual(result, [], 'empty chokepoint list → empty result regardless of signal state'); + }); +}); + +describe('live-tankers — bbox derivation', () => { + test('bbox is centered on the chokepoint with ±2° padding', () => { + const synth = { + id: 'test', + displayName: 'Test', + geoId: 'test', + relayName: 'Test', + portwatchName: 'Test', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: [], + lat: 26.5, + lon: 56.5, + }; + const bbox = bboxFor(synth); + assert.equal(bbox.swLat, 24.5); + assert.equal(bbox.swLon, 54.5); + assert.equal(bbox.neLat, 28.5); + assert.equal(bbox.neLon, 58.5); + }); + + test('bbox total span is 4° on both axes (under the 10° handler guard)', () => { + const synth = { + id: 'test', + displayName: 'Test', + geoId: 'test', + relayName: 'Test', + portwatchName: 'Test', + corridorRiskName: null, + baselineId: null, + shockModelSupported: false, + routeIds: [], + lat: 0, + lon: 0, + }; + const bbox = bboxFor(synth); + assert.equal(bbox.neLat - bbox.swLat, 4); + assert.equal(bbox.neLon - bbox.swLon, 4); + assert.ok(bbox.neLat - bbox.swLat <= 10, 'must stay under handler 10° guard'); + assert.ok(bbox.neLon - bbox.swLon <= 10, 'must stay under handler 10° guard'); + }); +}); diff --git a/tests/route-cache-tier.test.mjs b/tests/route-cache-tier.test.mjs index 1903d3123..8f91aade8 100644 --- a/tests/route-cache-tier.test.mjs +++ b/tests/route-cache-tier.test.mjs @@ -40,7 +40,7 @@ function extractGetRoutes() { function extractCacheTierKeys() { const gatewayPath = join(root, 'server', 'gateway.ts'); const src = readFileSync(gatewayPath, 'utf-8'); - const re = /'\/(api\/[^']+)':\s*'(fast|medium|slow|slow-browser|static|daily|no-store)'/g; + const re = /'\/(api\/[^']+)':\s*'(fast|medium|slow|slow-browser|static|daily|no-store|live)'/g; const entries = {}; let m; while ((m = re.exec(src)) !== null) { diff --git a/tests/server-handlers.test.mjs b/tests/server-handlers.test.mjs index 33f3198cc..765480e32 100644 --- a/tests/server-handlers.test.mjs +++ b/tests/server-handlers.test.mjs @@ -192,24 +192,37 @@ describe('getCacheKey determinism', () => { describe('getVesselSnapshot caching (HIGH-1)', () => { const src = readSrc('server/worldmonitor/maritime/v1/get-vessel-snapshot.ts'); - it('has per-variant cache slots (candidates=on vs off)', () => { - assert.match(src, /cache:\s*Record<'with'\s*\|\s*'without'/, - 'Cache should split on include_candidates so the large/small payloads do not share a slot'); - assert.match(src, /with:\s*\{\s*snapshot:\s*undefined/, - 'with-candidates slot should be initialized empty'); - assert.match(src, /without:\s*\{\s*snapshot:\s*undefined/, - 'without-candidates slot should be initialized empty'); + it('cache is keyed by request shape (candidates, tankers, quantized bbox)', () => { + // PR 3 (parity-push) replaced the prior `Record<'with'|'without'>` cache + // with a Map where the key embeds all three + // axes that change response payload: includeCandidates, includeTankers, + // and (when present) a 1°-quantized bbox. This prevents distinct bboxes + // from collapsing onto a single cached response. + assert.match(src, /const\s+cache\s*=\s*new\s+Map/, + 'cache should be a Map keyed by request shape'); + assert.match(src, /cacheKeyFor\s*\(/, + 'cacheKeyFor() helper should compose the cache key'); + // Key must distinguish includeCandidates, includeTankers, and bbox. + assert.match(src, /includeCandidates\s*\?\s*'1'\s*:\s*'0'/, + 'cache key must encode includeCandidates'); + assert.match(src, /includeTankers\s*\?\s*'1'\s*:\s*'0'/, + 'cache key must encode includeTankers'); }); - it('has 5-minute TTL cache', () => { - assert.match(src, /SNAPSHOT_CACHE_TTL_MS\s*=\s*300[_]?000/, - 'TTL should be 5 minutes (300000ms)'); + it('has split TTLs for base (5min) and live tanker / bbox (60s) reads', () => { + // Base path (density + military-detection consumers) keeps the prior + // 5-min cache. Live-tanker and bbox-filtered paths drop to 60s to honor + // the freshness contract that drives the Energy Atlas LiveTankersLayer. + assert.match(src, /SNAPSHOT_CACHE_TTL_BASE_MS\s*=\s*300[_]?000/, + 'base TTL should remain 5 minutes (300000ms) for density/disruption consumers'); + assert.match(src, /SNAPSHOT_CACHE_TTL_LIVE_MS\s*=\s*60[_]?000/, + 'live tanker / bbox TTL should be 60s to match the gateway live tier s-maxage'); }); it('checks cache before calling relay', () => { // fetchVesselSnapshot should check slot freshness before fetchVesselSnapshotFromRelay const cacheCheckIdx = src.indexOf('slot.snapshot && (now - slot.timestamp)'); - const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay(includeCandidates)'); + const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay('); assert.ok(cacheCheckIdx > -1, 'Should check slot freshness'); assert.ok(relayCallIdx > -1, 'Should have relay fetch function'); assert.ok(cacheCheckIdx < relayCallIdx, @@ -230,6 +243,25 @@ describe('getVesselSnapshot caching (HIGH-1)', () => { 'Should return stale cached snapshot from the selected slot when fresh relay fetch fails'); }); + it('rejects oversized bbox AND out-of-range coords with statusCode=400', () => { + // PR 3 (parity-push): server-side guard against a malicious or buggy + // global-bbox query that would pull every tanker through one request. + // Range guard added in #3402 review-fix: relay silently drops malformed + // bboxes and serves global capped subsets — handler MUST validate + // -90..90 / -180..180 before calling relay. Error must carry + // statusCode=400 or error-mapper.ts maps it to a generic 500. + assert.match(src, /MAX_BBOX_DEGREES\s*=\s*10/, + 'should declare a 10° max-bbox guard'); + assert.match(src, /class\s+BboxValidationError/, + 'should throw BboxValidationError on invalid bbox'); + assert.match(src, /readonly\s+statusCode\s*=\s*400/, + 'BboxValidationError must carry statusCode=400 (error-mapper surfaces it as HTTP 400 only when the error has a statusCode property)'); + assert.match(src, /lat\s*>=\s*-90\s*&&\s*lat\s*<=\s*90/, + 'must validate lat is in [-90, 90]'); + assert.match(src, /lon\s*>=\s*-180\s*&&\s*lon\s*<=\s*180/, + 'must validate lon is in [-180, 180]'); + }); + // NOTE: Full integration test (mocking fetch, verifying cache hits) requires // a TypeScript-capable test runner. This structural test verifies the pattern. });