Files
worldmonitor/docs/api/MaritimeService.openapi.yaml
Elie Habib 5c955691a9 feat(energy-atlas): live tanker map layer + contract (parity PR 3, plan U7-U8) (#3402)
* feat(energy-atlas): live tanker map layer + contract (PR 3, plan U7-U8)

Lands the third and final parity-push surface — per-vessel tanker positions
inside chokepoint bounding boxes, refreshed every 60s. Closes the visual
gap with peer reference energy-intel sites for the live AIS tanker view.

Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 3.
Codex-approved through 8 review rounds against origin/main @ 050073354.

U7 — Contract changes (relay + handler + proto + gateway + rate-limit + test):

- scripts/ais-relay.cjs: parallel `tankerReports` Map populated for AIS
  ship type 80-89 (tanker class) per ITU-R M.1371. SEPARATE from the
  existing `candidateReports` Map (military-only) so the existing
  military-detection consumer's contract stays unchanged. Snapshot
  endpoint extended to accept `bbox=swLat,swLon,neLat,neLon` + `tankers=true`
  query params, with bbox-filtering applied server-side. Tanker reports
  cleaned up on the same retention window as candidate reports; capped
  at 200 per response (10× headroom for global storage).
- proto/worldmonitor/maritime/v1/{get_,}vessel_snapshot.proto:
  - new `bool include_tankers = 6` request field
  - new `repeated SnapshotCandidateReport tanker_reports = 7` response
    field (reuses existing message shape; parallel to candidate_reports)
- server/worldmonitor/maritime/v1/get-vessel-snapshot.ts: REPLACES the
  prior 5-minute `with|without` cache with a request-keyed cache —
  (includeCandidates, includeTankers, quantizedBbox) — at 60s TTL for
  the live-tanker path and 5min TTL for the existing density/disruption
  consumers. Also adds 1° bbox quantization for cache-key reuse and a
  10° max-bbox guard (BboxTooLargeError) to prevent malicious clients
  from pulling all tankers through one query.
- server/gateway.ts: NEW `'live'` cache tier. CacheTier union extended;
  TIER_HEADERS + TIER_CDN_CACHE both gain entries with `s-maxage=60,
  stale-while-revalidate=60`. RPC_CACHE_TIER maps the maritime endpoint
  from `'no-store'` to `'live'` so the CDN absorbs concurrent identical
  requests across all viewers (without this, N viewers × 6 chokepoints
  hit AISStream upstream linearly).
- server/_shared/rate-limit.ts: ENDPOINT_RATE_POLICIES entry for the
  maritime endpoint at 60 req/min/IP — enough headroom for one user's
  6-chokepoint tab plus refreshes; flags only true scrape-class traffic.
- tests/route-cache-tier.test.mjs: regex extended to include `live` so
  the every-route-has-an-explicit-tier check still recognises the new
  mapping. Without this, the new tier would silently drop the maritime
  route from the validator's route map.

U8 — LiveTankersLayer consumer:

- src/services/live-tankers.ts: per-chokepoint fetcher with 60s in-memory
  cache. Promise.allSettled — never .all — so one chokepoint failing
  doesn't blank the whole layer (failed zones serve last-known data).
  Sources bbox centroids from src/config/chokepoint-registry.ts
  (CORRECT location — server/.../​_chokepoint-ids.ts strips lat/lon).
  Default chokepoint set: hormuz_strait, suez, bab_el_mandeb,
  malacca_strait, panama, bosphorus.
- src/components/DeckGLMap.ts: new `createLiveTankersLayer()` ScatterplotLayer
  styled by speed (anchored amber when speed < 0.5 kn, underway cyan,
  unknown gray); new `loadLiveTankers()` async loader with abort-controller
  cancellation. Layer instantiated when `mapLayers.liveTankers && this.liveTankers.length > 0`.
- src/config/map-layer-definitions.ts: `LayerDefinition` for `liveTankers`
  with `renderers: ['flat'], deckGLOnly: true` (matches existing
  storageFacilities/fuelShortages pattern). Added to `VARIANT_LAYER_ORDER.energy`
  near `ais` so getLayersForVariant() and sanitizeLayersForVariant()
  include it on the energy variant — without this addition the layer
  would be silently stripped even when toggled on.
- src/types/index.ts: `liveTankers?: boolean` on the MapLayers union.
- src/config/panels.ts: ENERGY_MAP_LAYERS + ENERGY_MOBILE_MAP_LAYERS
  both gain `liveTankers: true`. Default `false` everywhere else.
- src/services/maritime/index.ts: existing snapshot consumer pinned to
  `includeTankers: false` to satisfy the proto's new required field;
  preserves identical behavior for the AIS-density / military-detection
  surfaces.

Tests:
- npm run typecheck clean.
- 5 unit tests in tests/live-tankers-service.test.mjs cover the default
  chokepoint set (rejects ids that aren't in CHOKEPOINT_REGISTRY), the
  60s cache TTL pin (must match gateway 'live' tier s-maxage), and bbox
  derivation (±2° padding, total span under the 10° handler guard).
- tests/route-cache-tier.test.mjs continues to pass after the regex
  extension; the new maritime tier is correctly extracted.

Defense in depth:
- THREE-layer cache (CDN 'live' tier → handler bbox-keyed 60s → service
  in-memory 60s) means concurrent users hit the relay sub-linearly.
- Server-side 200-vessel cap on tanker_reports + client-side cap;
  protects layer render perf even on a runaway relay payload.
- Bbox-size guard (10° max) prevents a single global-bbox query from
  exfiltrating every tanker.
- Per-IP rate limit at 60/min covers normal use; flags scrape-class only.
- Existing military-detection contract preserved: `candidate_reports`
  field semantics unchanged; consumers self-select via include_tankers
  vs include_candidates rather than the response field changing meaning.

* fix(energy-atlas): wire LiveTankers loop + 400 bbox-range guard (PR3 review)

Three findings from review of #3402:

P1 — loadLiveTankers() was never called (DeckGLMap.ts:2999):
- Add ensureLiveTankersLoop() / stopLiveTankersLoop() helpers paired with
  the layer-enabled / layer-disabled branches in updateLayers(). The
  ensure helper kicks an immediate load + a 60s setInterval; idempotent
  so calling it on every layers update is safe.
- Wire stopLiveTankersLoop() into destroy() and into the layer-disabled
  branch so we don't hammer the relay when the layer is off.
- Layer factory now runs only when liveTankers.length > 0; ensureLoop
  fires on every observed-enabled tick so first-paint kicks the load
  even before the first tanker arrives.

P1 — bbox lat/lon range guard (get-vessel-snapshot.ts:253):
- Out-of-range bboxes (e.g. ne_lat=200) previously passed the size
  guard (200-195=5° < 10°) but failed at the relay, which silently
  drops the bbox param and returns a global capped subset — making
  the layer appear to "work" with stale phantom data.
- Add isValidLatLon() check inside extractAndValidateBbox(): every
  corner must satisfy [-90, 90] / [-180, 180] before the size guard
  runs. Failure throws BboxValidationError.

P2 — BboxTooLargeError surfaced as 500 instead of 400:
- server/error-mapper.ts maps errors to HTTP status by checking
  `'statusCode' in error`. The previous BboxTooLargeError extended
  Error without that property, so the mapper fell through to
  "unhandled error" → 500.
- Rename to BboxValidationError, add `readonly statusCode = 400`.
  Mapper now surfaces it as HTTP 400 with a descriptive reason.
- Keep BboxTooLargeError as a backwards-compat alias so existing
  imports / tests don't break.

Tests:
- Updated tests/server-handlers.test.mjs structural test to pin the
  new class name + statusCode + lat/lon range checks. 24 tests pass.
- typecheck (src + api) clean.

* fix(energy-atlas): thread AbortSignal through fetchLiveTankers (PR3 review #2)

P2 — AbortController was created + aborted but signal was never passed
into the actual fetch path (DeckGLMap.ts:3048 / live-tankers.ts:100):
- Toggling the layer off, destroying the map, or starting a new refresh
  did not actually cancel in-flight network work. A slow older refresh
  could complete after a newer one and overwrite this.liveTankers with
  stale data.

Threading:
- fetchLiveTankers() now accepts `options.signal: AbortSignal`. Signal
  is passed through to client.getVesselSnapshot() per chokepoint via
  the Connect-RPC client's standard `{ signal }` option.
- Per-zone abort handling: bail early if signal is already aborted
  before the fetch starts (saves a wasted RPC + cache write); re-check
  after the fetch resolves so a slow resolver can't clobber cache
  after the caller cancelled.

Stale-result race guard in DeckGLMap.loadLiveTankers:
- Capture controller in a local before storing on this.liveTankersAbort.
- After fetchLiveTankers resolves, drop the result if EITHER:
  - controller.signal is now aborted (newer load cancelled this one)
  - this.liveTankersAbort points to a different controller (a newer
    load already started + replaced us in the field)
- Without these guards, an older fetch that completed despite
  signal.aborted could still write to this.liveTankers and call
  updateLayers, racing with the newer load.

Tests: 1 new signature-pin test in tests/live-tankers-service.test.mts
verifies fetchLiveTankers accepts options.signal — guards against future
edits silently dropping the parameter and re-introducing the race.
6 tests pass. typecheck clean.

* fix(energy-atlas): bound vessel-snapshot cache via LRU eviction (PR3 review)

Greptile P2 finding: the in-process cache Map grows unbounded across the
serverless instance lifetime. Each distinct (includeCandidates,
includeTankers, quantizedBbox) triple creates a slot that's never evicted.
With 1° quantization and a misbehaving client the keyspace is ~64,000
entries — realistic load is ~12, so a 128-slot cap leaves 10x headroom
while making OOM impossible.

Implementation:
- SNAPSHOT_CACHE_MAX_SLOTS = 128.
- evictIfNeeded() walks insertion order and evicts the first slot whose
  inFlight is null. Slots with active fetches are skipped to avoid
  orphaning awaiting callers; we accept brief over-cap growth until
  in-flight settles.
- touchSlot() re-inserts a slot at the end of Map insertion order on
  hit / in-flight join / fresh write so it counts as most-recently-used.
2026-04-25 17:56:23 +04:00

453 lines
19 KiB
YAML

openapi: 3.1.0
info:
title: MaritimeService API
version: 1.0.0
paths:
/api/maritime/v1/get-vessel-snapshot:
get:
tags:
- MaritimeService
summary: GetVesselSnapshot
description: GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions.
operationId: GetVesselSnapshot
parameters:
- name: ne_lat
in: query
description: North-east corner latitude of bounding box.
required: false
schema:
type: number
format: double
- name: ne_lon
in: query
description: North-east corner longitude of bounding box.
required: false
schema:
type: number
format: double
- name: sw_lat
in: query
description: South-west corner latitude of bounding box.
required: false
schema:
type: number
format: double
- name: sw_lon
in: query
description: South-west corner longitude of bounding box.
required: false
schema:
type: number
format: double
- name: include_candidates
in: query
description: |-
When true, populate VesselSnapshot.candidate_reports with per-vessel
position reports. Clients with no position callbacks should leave this
false to keep responses small.
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
content:
application/json:
schema:
$ref: '#/components/schemas/GetVesselSnapshotResponse'
"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/maritime/v1/list-navigational-warnings:
get:
tags:
- MaritimeService
summary: ListNavigationalWarnings
description: ListNavigationalWarnings retrieves active maritime safety warnings from NGA.
operationId: ListNavigationalWarnings
parameters:
- name: page_size
in: query
description: Maximum items per page (1-100).
required: false
schema:
type: integer
format: int32
- name: cursor
in: query
description: Cursor for next page.
required: false
schema:
type: string
- name: area
in: query
description: Optional area filter (e.g., "NAVAREA IV", "Persian Gulf").
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListNavigationalWarningsResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
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')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
GetVesselSnapshotRequest:
type: object
properties:
neLat:
type: number
format: double
description: North-east corner latitude of bounding box.
neLon:
type: number
format: double
description: North-east corner longitude of bounding box.
swLat:
type: number
format: double
description: South-west corner latitude of bounding box.
swLon:
type: number
format: double
description: South-west corner longitude of bounding box.
includeCandidates:
type: boolean
description: |-
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
properties:
snapshot:
$ref: '#/components/schemas/VesselSnapshot'
description: GetVesselSnapshotResponse contains the vessel traffic snapshot.
VesselSnapshot:
type: object
properties:
snapshotAt:
type: integer
format: int64
description: 'Snapshot timestamp, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
densityZones:
type: array
items:
$ref: '#/components/schemas/AisDensityZone'
disruptions:
type: array
items:
$ref: '#/components/schemas/AisDisruption'
sequence:
type: integer
format: int32
description: |-
Monotonic sequence number from the relay. Clients use this to detect stale
responses during polling.
status:
$ref: '#/components/schemas/AisSnapshotStatus'
candidateReports:
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
properties:
id:
type: string
minLength: 1
description: Zone identifier.
name:
type: string
description: Zone name (e.g., "Strait of Malacca").
location:
$ref: '#/components/schemas/GeoCoordinates'
intensity:
type: number
maximum: 100
minimum: 0
format: double
description: Traffic intensity score (0-100).
deltaPct:
type: number
format: double
description: Change from baseline as a percentage.
shipsPerDay:
type: integer
format: int32
description: Estimated ships per day.
note:
type: string
description: Analyst note.
required:
- id
description: AisDensityZone represents a zone of concentrated vessel traffic.
GeoCoordinates:
type: object
properties:
latitude:
type: number
maximum: 90
minimum: -90
format: double
description: Latitude in decimal degrees (-90 to 90).
longitude:
type: number
maximum: 180
minimum: -180
format: double
description: Longitude in decimal degrees (-180 to 180).
description: GeoCoordinates represents a geographic location using WGS84 coordinates.
AisDisruption:
type: object
properties:
id:
type: string
minLength: 1
description: Disruption identifier.
name:
type: string
description: Descriptive name.
type:
type: string
enum:
- AIS_DISRUPTION_TYPE_UNSPECIFIED
- AIS_DISRUPTION_TYPE_GAP_SPIKE
- AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION
description: |-
AisDisruptionType represents the type of AIS tracking anomaly.
Maps to TS union: 'gap_spike' | 'chokepoint_congestion'.
location:
$ref: '#/components/schemas/GeoCoordinates'
severity:
type: string
enum:
- AIS_DISRUPTION_SEVERITY_UNSPECIFIED
- AIS_DISRUPTION_SEVERITY_LOW
- AIS_DISRUPTION_SEVERITY_ELEVATED
- AIS_DISRUPTION_SEVERITY_HIGH
description: AisDisruptionSeverity represents the severity of an AIS disruption.
changePct:
type: number
format: double
description: Percentage change from normal.
windowHours:
type: integer
format: int32
description: Analysis window in hours.
darkShips:
type: integer
format: int32
description: Number of dark ships (AIS off) detected.
vesselCount:
type: integer
format: int32
description: Number of vessels in the affected area.
region:
type: string
description: Region name.
description:
type: string
description: Human-readable description.
required:
- id
description: AisDisruption represents a detected anomaly in AIS vessel tracking data.
AisSnapshotStatus:
type: object
properties:
connected:
type: boolean
description: Whether the relay WebSocket is connected to the AIS provider.
vessels:
type: integer
format: int32
description: Number of vessels currently tracked by the relay.
messages:
type: integer
format: int32
description: Total AIS messages processed in the current session.
description: AisSnapshotStatus reports relay health at the time of the snapshot.
SnapshotCandidateReport:
type: object
properties:
mmsi:
type: string
description: Maritime Mobile Service Identity.
name:
type: string
description: Vessel name (may be empty if unknown).
lat:
type: number
format: double
description: Latitude in decimal degrees.
lon:
type: number
format: double
description: Longitude in decimal degrees.
shipType:
type: integer
format: int32
description: AIS ship type code (0 if unknown).
heading:
type: integer
format: int32
description: Heading in degrees (0-359, or 511 for unavailable).
speed:
type: number
format: double
description: Speed over ground in knots.
course:
type: integer
format: int32
description: Course over ground in degrees.
timestamp:
type: integer
format: int64
description: 'Report timestamp, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
description: |-
SnapshotCandidateReport is a per-vessel position report attached to a
snapshot. Used to drive the client-side position callback system.
ListNavigationalWarningsRequest:
type: object
properties:
pageSize:
type: integer
format: int32
description: Maximum items per page (1-100).
cursor:
type: string
description: Cursor for next page.
area:
type: string
description: Optional area filter (e.g., "NAVAREA IV", "Persian Gulf").
description: ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings.
ListNavigationalWarningsResponse:
type: object
properties:
warnings:
type: array
items:
$ref: '#/components/schemas/NavigationalWarning'
pagination:
$ref: '#/components/schemas/PaginationResponse'
description: ListNavigationalWarningsResponse contains navigational warnings matching the request.
NavigationalWarning:
type: object
properties:
id:
type: string
description: Warning identifier.
title:
type: string
description: Warning title.
text:
type: string
description: Full warning text.
area:
type: string
description: Geographic area affected.
location:
$ref: '#/components/schemas/GeoCoordinates'
issuedAt:
type: integer
format: int64
description: 'Warning issue date, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
expiresAt:
type: integer
format: int64
description: 'Warning expiry date, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
authority:
type: string
description: Warning source authority.
description: NavigationalWarning represents a maritime safety warning from NGA.
PaginationResponse:
type: object
properties:
nextCursor:
type: string
description: Cursor for fetching the next page. Empty string indicates no more pages.
totalCount:
type: integer
format: int32
description: Total count of items matching the query, if known. Zero if the total is unknown.
description: PaginationResponse contains pagination metadata returned alongside list results.