From c22ad6af32be8afa4ee495258fff736aef66d686 Mon Sep 17 00:00:00 2001 From: Ashfaq <105435085+Ashfaqbs@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:15:55 +0530 Subject: [PATCH 01/19] fix: handle antimeridian crossing in clampBoundsAroundCenter east - west assumed a linear longitude axis, so a viewport crossing the antimeridian (west=179.98, east=-179.98) produced a negative span and unnormalized output coordinates outside [-180, 180). Wrap the span through 360 and normalize the clamped west/east back into range. Fixes #352. --- src/data/trafficBounds.js | 37 ++++++++++++++++++++++++++++++--- src/data/trafficBounds.test.mjs | 27 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 3b0404c3b..9f303785e 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -113,6 +113,33 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' }; } +/** + * Wrapped longitude span between two meridians, in degrees. + * + * Longitude is cyclic, so a box crossing the antimeridian (±180°) has + * `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west` + * goes negative in that case; wrap through 360° instead. + * + * @param {number} west - West edge longitude (degrees). + * @param {number} east - East edge longitude (degrees). + * @returns {number} Span in degrees, always >= 0. + */ +function longitudeSpanDeg(west, east) { + const raw = east - west; + return raw >= 0 ? raw : raw + 360; +} + +/** + * Normalize a longitude into [-180, 180). Matches the wrap idiom already + * used by `destinationPoint()` above. + * + * @param {number} lon - Longitude in degrees. + * @returns {number} Longitude normalized to [-180, 180). + */ +function normalizeLonDeg(lon) { + return ((lon + 540) % 360) - 180; +} + /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * @@ -120,6 +147,10 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK * but centers the box on the derived look-at point instead of the view * rectangle's midpoint. Idempotent when `center` is the box's own midpoint. * + * Longitude is handled as a cyclic axis: the input span wraps correctly + * across the antimeridian, and the output west/east are normalized back into + * [-180, 180) instead of drifting outside the normal coordinate range. + * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). * @param {{lat:number, lon:number}} center - Fetch center (degrees). @@ -128,11 +159,11 @@ export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullK */ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg); - const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg); + const lonSpan = Math.min(longitudeSpanDeg(bounds.west, bounds.east), maxSpanDeg); return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: center.lon - lonSpan / 2, - east: center.lon + lonSpan / 2, + west: normalizeLonDeg(center.lon - lonSpan / 2), + east: normalizeLonDeg(center.lon + lonSpan / 2), }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..63450dbb9 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -108,3 +108,30 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re- const twice = clampBoundsAroundCenter(once, midpoint, 0.05); assert.deepEqual(twice, once); }); + +test('span clamp handles bounds crossing the antimeridian', () => { + const bounds = { + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }; + + const center = { + lat: 0, + lon: 179.99, + }; + + const result = clampBoundsAroundCenter(bounds, center, 0.05); + + assert.ok(result.west >= -180 && result.west < 180); + assert.ok(result.east >= -180 && result.east < 180); + + // Wrapped longitude span should remain about 0.04 degrees. + const span = + result.east >= result.west + ? result.east - result.west + : result.east - result.west + 360; + + assert.ok(Math.abs(span - 0.04) < 1e-12); +}); From 7da812eddd1ad4f131645e8c6a9f4f367a7b067a Mon Sep 17 00:00:00 2001 From: DeliberateEnsemble Date: Sun, 13 Sep 2026 23:06:51 -0400 Subject: [PATCH 02/19] fix: report keyless OpenSky access in setup doctor --- CHANGELOG.md | 5 +++++ README.md | 7 ++++--- docs/CURRENT-STATE.md | 1 + scripts/setup-doctor.mjs | 32 ++++++++++++++++++++++++++++---- src/setupDoctor.test.mjs | 38 +++++++++++++++++++++++++++++++++++++- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c308b78..f0aa59192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Make `npm run doctor` report keyless anonymous OpenSky access for explicit + `OPENSKY_AUTH_MODE=anon` and missing OAuth client pairs while retaining the + existing OAuth-pair capability wording. OpenSky proxy authentication is + unchanged. + - Traffic now retries a failed destination after city navigation without a layer toggle. Camera departure cancels pending work, arrival checks the final view, and superseded requests cannot keep a newer view loading. diff --git a/README.md b/README.md index 4135028b5..f79fedd67 100644 --- a/README.md +++ b/README.md @@ -405,9 +405,10 @@ Add these if you need higher polling allowances. where each configured provider was found without printing credential values. On macOS its Keychain-aware result previews `./scripts/dev-fresh.sh`; plain `npm run dev` reads only explicit environment and Vite dotenv values. The -OpenSky summary reports only OAuth client-pair presence, not the resolved -runtime mode or credential validity; Basic and credentials-file modes remain -advanced `dev-fresh.sh` configuration. +OpenSky summary reports keyless anonymous access for explicit `anon` or when +no OAuth client pair is present, and retains presence-only wording for a +complete OAuth pair unless `anon` is selected. Basic and credentials-file +modes remain advanced `dev-fresh.sh` configuration.
Advanced setup: environment variables and macOS Keychain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a009d68f7..7009f810f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2942,6 +2942,7 @@ are omitted rather than framing the wrong part of the globe. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) +- Setup doctor resolves `OPENSKY_AUTH_MODE` from the environment and dotenv files. Explicit `anon` and a missing OAuth client pair report keyless anonymous access (rate-limited); a complete OAuth pair retains the existing presence-only capability wording unless `anon` is selected. The proxy's auth behavior is unchanged. - Google key expected in Keychain service `google-maps-api` (or `GOOGLE_MAPS_API_KEY`, or `.env`) - OpenSky credentials expected in Keychain service `opensky-network` (or env, or `.env`); `OPENSKY_AUTH_MODE` and `OPENSKY_CREDENTIALS_FILE` read from `.env` too - Optional-key precedence in `dev-fresh.sh` is uniform — explicit shell env, then `.env`, then Keychain: `OPENAI_API_KEY` (Keychain `openai-api`/`api-key` — voice + HUD summary), `AISSTREAM_API_KEY` (`aisstream-api`/`api-key` — live vessels), `CESIUM_ION_TOKEN` (`cesium-ion`/`token` — Bing stacks), `TOMTOM_API_KEY` (`tomtom-api`/`api-key` — live traffic flow), `FIRMS_MAP_KEY` (`firms-map`/`map-key` — live fires), `LL2_API_TOKEN` (`.env` only) diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 37c01156e..a9169839c 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'; import { selectMapStartupRoute } from '../src/mapStartup.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OPENSKY_AUTH_MODE_DEFAULT = 'oauth'; +const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']); export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] }, @@ -100,6 +102,21 @@ export function readDoctorDotenvValue( return String(values[key] ?? ''); } +/** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */ +export function resolveOpenSkyAuthMode({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE'); + const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim(); + const dotenvMode = authoritativeEnvironment && environmentDefinesMode + ? '' + : readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir); + const mode = (environmentMode || dotenvMode).trim().toLowerCase(); + return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -127,8 +144,13 @@ export function resolveCredential(spec, { return { configured: false, source: null }; } -export function buildCapabilitySummary(credentials) { +export function buildCapabilitySummary( + credentials, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, +) { const configured = (name) => credentials[name]?.configured === true; + const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const anonymousOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase() === 'anon'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '', @@ -139,9 +161,9 @@ export function buildCapabilitySummary(credentials) { : route === 'google-ion' ? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available' : 'Esri World Imagery (keyless satellite basemap) with keyless terrain', - flights: configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET') + flights: hasOAuthCredentials && !anonymousOpenSkyMode ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' - : 'OpenSky OAuth credentials not configured', + : 'OpenSky keyless anonymous access (rate-limited)', voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added', vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added', fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added', @@ -172,7 +194,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment : { available: false, version: null }, dependenciesInstalled, credentials, - capabilities: buildCapabilitySummary(credentials), + capabilities: buildCapabilitySummary(credentials, { + openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment }), + }), }; } diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 857a96c78..80720d5aa 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -12,6 +12,7 @@ import { isConfiguredValue, npmProcessSpec, readDoctorDotenvValue, + resolveOpenSkyAuthMode, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -145,6 +146,41 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', () } }); +test('doctor resolves OpenSky auth mode with launcher-compatible precedence', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-mode-')); + try { + writeFileSync(path.join(root, '.env'), 'OPENSKY_AUTH_MODE=anon\n'); + assert.equal(resolveOpenSkyAuthMode({ environment: {}, rootDir: root }), 'anon'); + assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'oauth' }, rootDir: root }), 'oauth'); + assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: '' }, rootDir: root }), 'anon'); + assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'invalid' }, rootDir: root }), 'oauth'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('doctor reports keyless anonymous OpenSky access for explicit anon mode', () => { + const credentials = { + OPENSKY_CLIENT_ID: { configured: true }, + OPENSKY_CLIENT_SECRET: { configured: true }, + }; + assert.equal( + buildCapabilitySummary(credentials, { openSkyAuthMode: 'anon' }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); +}); + +test('doctor retains OAuth capability wording for a complete client pair', () => { + const credentials = { + OPENSKY_CLIENT_ID: { configured: true }, + OPENSKY_CLIENT_SECRET: { configured: true }, + }; + assert.equal( + buildCapabilitySummary(credentials, { openSkyAuthMode: 'oauth' }).flights, + 'OpenSky OAuth credentials present (runtime mode and validity not verified)', + ); +}); + test('doctor describes the credential ladder without exposing values', () => { const credentials = { GOOGLE_MAPS_API_KEY: { configured: false }, @@ -163,7 +199,7 @@ test('doctor describes the credential ladder without exposing values', () => { assert.match(capabilities.map, /Bing and world-terrain stacks/); assert.equal(capabilities.voice, 'available'); assert.match(capabilities.missions, /token allowance/); - assert.equal(capabilities.flights, 'OpenSky OAuth credentials not configured'); + assert.equal(capabilities.flights, 'OpenSky keyless anonymous access (rate-limited)'); const report = formatSetupReport({ ready: true, From 9e71081a5a2a4f27d43428eb096ac4c65b60df4c Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:23:17 -0500 Subject: [PATCH 03/19] fix(server): stop relaying upstream and JS error text to the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported onto the split provider layout: the routes now live in server/providers/openai/{hud-summary,realtime}.js and the camera health entry in server/providers/cctv.js. The routes #263 names — celestrak, terrain, adsbdb — were sanitized by #289. The same defect had moved: - /api/openai/hud-summary relayed `data.error.message` verbatim on a non-ok upstream. That is OpenAI's own error text, carrying request ids, organization hints and quota wording. Its catch echoed the JS error too. - /api/realtime/token echoed the JS error when minting failed, which for a network fault is a resolver message naming the upstream host. - cctvProxy stored the raw error as a camera's health `message`. This is the least obvious of the three and the reason the PR is worth its size: GET /api/cctv/health serializes `message`, and src/layers/cctv/frames.js renders it as the camera's status label, so the detail reached the screen through a different door than the sanitized 502 beside it. Logs name the failure and the upstream status without the text, matching the Launch Library and terrain paths; these lines get pasted into issues. Coverage extends the existing harnesses rather than adding files — localServices.test.mjs drives both OpenAI routes over a mocked upstream, mediaProviders.test.mjs drives a failing media fetch and then reads /health. All three assertions fail on the previous implementation. Refs #263. Does not touch /api/realtime/debug-log's identical echo; that route is the subject of the #23 PR, so the two do not overlap a line. --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 1 + server/providers/cctv.js | 7 ++++- server/providers/openai/hud-summary.js | 13 +++++--- server/providers/openai/realtime.js | 7 +++-- src/tooling/localServices.test.mjs | 33 ++++++++++++++++++++ src/tooling/mediaProviders.test.mjs | 42 ++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18e8da84..fb2a8101a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Three proxy paths no longer relay upstream or JS error text to the client. The HUD summary passed OpenAI's own `error.message` through whenever upstream was not ok, carrying request ids and quota wording; the Realtime token route echoed the JS error, which for a network fault names the upstream host; and a failed CCTV media fetch stored the raw errno as the camera's health message, which reaches the screen through `GET /api/cctv/health` rather than through the sanitized response beside it. Logs now name the failure and the upstream status without the text. + - Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation. - Separate military-flight records and acquisition from rendering while preserving ground-model ownership, source units and follow behavior. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee9ee6015..ee5458a07 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3192,6 +3192,7 @@ are omitted rather than framing the wrong part of the globe. - A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot. - GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded. - Proxy error payloads are sanitized (no internal error details returned to clients). +- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` answers a fixed string when minting fails, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is. - `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`. - `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache. - `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context. diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 5f601532f..69512e7c5 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -308,11 +308,16 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError'; + // GET /api/cctv/health serializes `message`, and the CCTV panel + // renders it as a status label, so the raw error would leave the + // server by a different door than the sanitized body below and + // land on screen. Keep the detail in the log. + console.warn('[CCTV Proxy] media fetch failed'); setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', label: source?.provider || 'Configured source', - message: error?.message || 'Media fetch failed', + message: 'Media fetch failed', }); res.writeHead(timedOut ? 504 : 502, { 'Content-Type': 'application/json', diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 54c065412..92d38f2e8 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -78,20 +78,23 @@ async function handleHudSummary(req, res) { res.statusCode = response.ok && summary ? 200 : response.status || 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); + if (!response.ok) + console.warn(`[hud-summary] upstream HTTP ${response.status}`); res.end( JSON.stringify({ summary: summary || null, - error: response.ok - ? null - : data.error?.message || 'OpenAI HUD summary request failed', + // Never relay `data.error.message`: that is OpenAI's own wording, and + // it carries request ids, organization hints and quota phrasing. + error: response.ok ? null : 'OpenAI HUD summary request failed', }), ); - } catch (error) { + } catch { + console.warn('[hud-summary] request failed'); res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ - error: error?.message || 'OpenAI HUD summary request failed', + error: 'OpenAI HUD summary request failed', }), ); } diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 53c9d60da..74a2eccb9 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -146,12 +146,15 @@ function createRealtimeTokenHandler({ res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); } res.end(body); - } catch (error) { + } catch { + // For a network fault this was a resolver message naming the upstream + // host; the client only needs to know the mint failed. + console.warn('[realtime-token] mint failed'); res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ - error: error?.message || 'Failed to create Realtime token', + error: 'Failed to create Realtime token', }), ); } diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index d7f2e4ba3..395dd4f09 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -283,3 +283,36 @@ test('Realtime service configuration selects compatible endpoint/model without f assert.equal(response.headers['cache-control'], 'no-store'); assert.doesNotMatch(response.body, /server-fixture|voice\.example/); }); + +test('OpenAI routes answer generically when the upstream or the request fails', async (t) => { + env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); + env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); + const leak = + 'fixture-upstream-secret req_fixture_1234 org-fixture quota exhausted'; + + // `data.error.message` is OpenAI's own wording — request ids, organization + // hints, quota phrasing — and was relayed verbatim whenever upstream was not ok. + t.mock.method(globalThis, 'fetch', async () => + Response.json({ error: { message: leak } }, { status: 429 }), + ); + const summary = await request( + install(openAiRealtimeProxy()).get('/api/openai/hud-summary'), + { method: 'POST', body: JSON.stringify({ context: {} }) }, + ); + assert.equal(summary.json().error, 'OpenAI HUD summary request failed'); + assert.equal(summary.body.includes('req_fixture_1234'), false); + assert.equal(summary.body.includes('fixture-upstream-secret'), false); + + // A network fault surfaced a resolver message naming the upstream host. + t.mock.restoreAll(); + t.mock.method(globalThis, 'fetch', async () => { + throw Error(`getaddrinfo ENOTFOUND api.openai.com ${leak}`); + }); + const token = await request( + install(openAiRealtimeProxy()).get('/api/realtime/token'), + ); + assert.equal(token.status, 502); + assert.deepEqual(token.json(), { error: 'Failed to create Realtime token' }); + assert.equal(token.body.includes('api.openai.com'), false); + assert.equal(token.body.includes('fixture-upstream-secret'), false); +}); diff --git a/src/tooling/mediaProviders.test.mjs b/src/tooling/mediaProviders.test.mjs index cbfcd0624..9642f55b6 100644 --- a/src/tooling/mediaProviders.test.mjs +++ b/src/tooling/mediaProviders.test.mjs @@ -149,3 +149,45 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { assert.equal(cancelled, true); }); } + +test('a failed CCTV media fetch reports a fixed health message, not the error text', async (t) => { + isolate(t); + const root = mkdtempSync(path.join(tmpdir(), 'gev-cctv-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'config')); + writeFileSync( + path.join(root, 'config/cctv_sources.austin.json'), + JSON.stringify([ + { + id: 'leaky', + name: 'Leaky camera', + lat: 30.27, + lon: -97.74, + feedType: 'video', + url: 'https://cams.fixture.invalid/leaky.m3u8', + }, + ]), + ); + const leak = + 'connect ETIMEDOUT 203.0.113.9:443 C:\fixture\secret-path\cams.json'; + t.mock.restoreAll(); + t.mock.method(globalThis, 'fetch', () => { + throw Error(leak); + }); + + const call = install(cctvProxy({ sourceRoot: root })); + const media = await call('/media/leaky'); + assert.equal(media.status, 502); + assert.equal(media.body.includes('203.0.113.9'), false); + + // GET /health is the second door: src/layers/cctv/frames.js renders each + // entry's `message` as the camera's status label, so a raw errno stored here + // reaches the screen even though the response above is sanitized. + const camera = JSON.parse((await call('/health')).body).cameras.find( + (entry) => entry.id === 'leaky', + ); + assert.equal(camera.status, 'degraded'); + assert.equal(camera.message, 'Media fetch failed'); + assert.equal(camera.message.includes('ETIMEDOUT'), false); + assert.equal(camera.message.includes('secret-path'), false); +}); From 44817e128636dc8e1202fabf6e884f5e75e66264 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:19:47 -0500 Subject: [PATCH 04/19] fix(realtime): bound the debug-log sink and stop blocking on its writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported onto the split provider layout: the route now lives in server/providers/openai/debug-log.js and its state belongs to the handler the factory returns rather than to module scope. /api/realtime/debug-log is the one route in its family left unhardened. Both siblings enforce a limiter through openai/rate-limit.js, and this was the only handler still writing with fs.appendFileSync on the request path. - Always-on per-IP limiter (120/min, 400 global). Deliberately not the opt-in openAiRateLimiter(): that one defaults to unlimited so throttling cannot silently change what a user spends, and this sink spends disk and event-loop time rather than provider quota. - Serialized async appends with a 32 MB rotation ceiling and one retained generation. REALTIME_DEBUG_LOG_MAX_BYTES caps a single request body; it never bounded the file those bodies accumulate into, so any local page could grow .gev-logs/realtime-conversations.jsonl for as long as the dev server ran. Serializing also closes a read-then-write race on the size check, where concurrent callers all stat under the ceiling and none rotates. - Generic 400/500 answers separating a malformed record or oversized body from a failed write, instead of echoing error.message — an errno and an absolute path for an fs failure. - readRequestBody marks an oversized body with code 'BODY_TOO_LARGE', the marker its sibling readRequestBodyCapped already sets, so the two cases are distinguishable without matching on text. Coverage extends src/tooling/localServices.test.mjs, which already drives the real handler over a temporary sourceRoot. Both new tests fail on the previous implementation and pass with this one. Refs #23. Does not add authentication: these proxies have no auth mechanism today, so introducing one is a maintainer design decision. --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 1 + server/providers/common/request.js | 6 +- server/providers/openai/debug-log.js | 85 +++++++++++++++++++++++----- src/tooling/localServices.test.mjs | 76 +++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18e8da84..7f90284a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- The Realtime debug-log endpoint is bounded on every axis it was not: an always-on per-client rate limit, asynchronous appends through a serialized queue instead of a synchronous write on the request path, and rotation of the log file at 32 MB keeping one prior generation. The 8 MB cap applied to a single request body and never to the file those requests accumulated into, so any local page could grow it for as long as the dev server ran. A malformed record and a failed write are now told apart, 400 from 500, and neither answer carries the error text. + - Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation. - Separate military-flight records and acquisition from rendering while preserving ground-model ownership, source units and follow behavior. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee9ee6015..277521d3b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3201,6 +3201,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. +- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. ## UI/UX Runtime Defaults diff --git a/server/providers/common/request.js b/server/providers/common/request.js index ab261d779..11e0f128f 100644 --- a/server/providers/common/request.js +++ b/server/providers/common/request.js @@ -21,7 +21,11 @@ function readRequestBody(req, maxBytes = 1024 * 1024) { req.on('data', (chunk) => { total += chunk.length; if (total > maxBytes) { - reject(new Error(`Request body exceeds ${maxBytes} bytes`)); + // Same marker readRequestBodyCapped sets, so a caller can tell an + // oversized body from a genuine failure without matching on text. + const error = new Error(`Request body exceeds ${maxBytes} bytes`); + error.code = 'BODY_TOO_LARGE'; + reject(error); req.destroy(); return; } diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index f7dd42154..c5c60f6e0 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -1,16 +1,61 @@ import { defaultSourceRoot } from '../common/source-root.js'; import path from 'node:path'; import { readRequestBody } from '../common/request.js'; -import fs from 'node:fs'; +import { promises as fsp } from 'node:fs'; +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +/** Cap on one request body. */ const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024; +/** + * Ceiling for the live log before it rotates. One generation is kept + * (`…jsonl.1`), so the sink is bounded at twice this however long a session + * runs. REALTIME_DEBUG_LOG_MAX_BYTES caps a single request; it never bounded + * the file those requests accumulate into. + */ +const REALTIME_DEBUG_LOG_MAX_FILE_BYTES = 32 * 1024 * 1024; + +/** + * Per-IP write ceiling. Always on, unlike the opt-in limiter the cost-bearing + * OpenAI routes share: throttling those by default would change what a user + * spends, while this sink spends disk and event-loop time. A voice session + * writes far below this, so the cap needs no configuration to stay invisible. + */ +const REALTIME_DEBUG_LOG_MAX_PER_MIN = 120; + function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { - const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs'); - const REALTIME_DEBUG_LOG_FILE = path.join( - REALTIME_DEBUG_LOG_DIR, - 'realtime-conversations.jsonl', - ); + const logDir = path.join(sourceRoot, '.gev-logs'); + const logFile = path.join(logDir, 'realtime-conversations.jsonl'); + const allow = makeRateLimiter({ + windowMs: 60_000, + max: REALTIME_DEBUG_LOG_MAX_PER_MIN, + globalMax: 400, + }); + + // Appends are chained rather than fired concurrently because the rotation + // check is a read-then-write on file size: run in parallel, every caller + // stats a file still under the ceiling, none of them rotates, and the log + // grows past it. The records themselves are O_APPEND and never interleave. + let queue = Promise.resolve(); + + function append(line) { + const done = queue.then(async () => { + await fsp.mkdir(logDir, { recursive: true }); + const size = await fsp.stat(logFile).then( + (stat) => stat.size, + () => 0, + ); + if (size + Buffer.byteLength(line) > REALTIME_DEBUG_LOG_MAX_FILE_BYTES) { + await fsp.rename(logFile, `${logFile}.1`); + } + await fsp.appendFile(logFile, line); + }); + // The caller still sees its own rejection, but one failed append must not + // poison the chain for every request queued behind it. + queue = done.catch(() => {}); + return done; + } + return async (req, res) => { if (req.method !== 'POST') { res.statusCode = 405; @@ -19,12 +64,19 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { return; } + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + try { const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES); const record = JSON.parse(body || '{}'); - fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true }); - fs.appendFileSync( - REALTIME_DEBUG_LOG_FILE, + await append( `${JSON.stringify({ loggedAt: new Date().toISOString(), ...record, @@ -33,13 +85,16 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { res.statusCode = 204; res.end(); } catch (error) { - res.statusCode = 400; + // A malformed record or an oversized body is the caller's fault; a failed + // write is ours, and only the second is worth a log line. Neither answer + // carries the error text, which for an fs failure is an errno and an + // absolute path. + const callerFault = + error instanceof SyntaxError || error?.code === 'BODY_TOO_LARGE'; + if (!callerFault) console.warn('[realtime-debug-log] append failed'); + res.statusCode = callerFault ? 400 : 500; res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - error: error?.message || 'Failed to write Realtime debug log', - }), - ); + res.end(JSON.stringify({ error: 'Failed to write Realtime debug log' })); } }; } diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index d7f2e4ba3..3ed1ef504 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -283,3 +283,79 @@ test('Realtime service configuration selects compatible endpoint/model without f assert.equal(response.headers['cache-control'], 'no-store'); assert.doesNotMatch(response.body, /server-fixture|voice\.example/); }); + +test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => { + const sourceRoot = root(t); + const handler = install(openAiRealtimeProxy({ sourceRoot })).get( + '/api/realtime/debug-log', + ); + const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'); + const write = (record) => + request(handler, { method: 'POST', body: JSON.stringify(record) }); + + // A malformed record is the caller's fault and a 400; neither answer carries + // the error text, which for a write failure is an errno and an absolute path. + const malformed = await request(handler, { method: 'POST', body: '{nope' }); + assert.equal(malformed.status, 400); + assert.deepEqual(malformed.json(), { + error: 'Failed to write Realtime debug log', + }); + + // The limiter is always on, unlike the opt-in one the cost-bearing routes + // share: 120/min per IP, far above what a voice session writes. + let limited = null; + let accepted = 0; + for (let n = 0; n < 130 && !limited; n += 1) { + const response = await write({ n }); + if (response.status === 429) limited = response; + else if (response.status === 204) accepted += 1; + } + assert.ok(limited, 'the sink refuses a caller past its per-minute ceiling'); + assert.equal(limited.headers['retry-after'], '5'); + assert.deepEqual(limited.json(), { error: 'Rate limit exceeded' }); + // The limiter counts requests rather than successful writes, so the malformed + // record above already spent one of the 120 slots. + assert.equal(accepted, 119); + + // Every accepted record is on disk and parses: the queue serializes appends, + // so none was lost or truncated by the ones beside it. + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean); + assert.equal(lines.length, accepted); + for (const line of lines) assert.doesNotThrow(() => JSON.parse(line)); + assert.ok(lines.every((line) => JSON.parse(line).loggedAt)); +}); + +test('the debug log rotates instead of growing without bound', async (t) => { + const sourceRoot = root(t); + const handler = install(openAiRealtimeProxy({ sourceRoot })).get( + '/api/realtime/debug-log', + ); + const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'); + + // 8 MB bounds one request body; nothing bounded the file until now, so a + // single page could grow it for as long as the dev server ran. Each record + // here is ~1 MB, well inside the body cap. + const pad = 'x'.repeat(1024 * 1024); + for (let n = 0; n < 40; n += 1) { + assert.equal( + ( + await request(handler, { + method: 'POST', + body: JSON.stringify({ n, pad }), + }) + ).status, + 204, + ); + } + + const live = statSync(file).size; + const previous = statSync(`${file}.1`).size; + assert.ok(live <= 32 * 1024 * 1024, `live log under the ceiling (${live})`); + assert.ok( + live + previous <= 64 * 1024 * 1024, + 'both generations together stay within twice the ceiling', + ); + // Unrotated, these records would be ~42 MB in one file. + assert.ok(live + previous < 42 * 1024 * 1024); + assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained'); +}); From 3308569edde13b6aa911ab20983ff4b4cd354d63 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:06:11 -0500 Subject: [PATCH 05/19] docs(cctv): the media-fetch log line drops the error text on purpose The comment claimed "Keep the detail in the log" while the line it introduces carries none, so the next reader would have read the omission as an oversight and added the errno back. Detail-free is the deliberate choice: a media-fetch failure names the camera's upstream host, and these lines get pasted into issues. Say that, and say why the catch at the bottom of this file may still log its error text. --- server/providers/cctv.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 69512e7c5..85cece004 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -311,7 +311,12 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { // GET /api/cctv/health serializes `message`, and the CCTV panel // renders it as a status label, so the raw error would leave the // server by a different door than the sanitized body below and - // land on screen. Keep the detail in the log. + // land on screen. + // + // The log line drops the text too, unlike the catch at the bottom + // of this file: a media-fetch failure names the camera's upstream + // host, and these lines get pasted into issues. The status codes + // below carry the diagnosis — 504 for a timeout, 502 otherwise. console.warn('[CCTV Proxy] media fetch failed'); setHealth(cameraId, { status: 'degraded', From 830c372c12f98b641eb2cceb902496b57d07199d Mon Sep 17 00:00:00 2001 From: Ashfaq <105435085+Ashfaqbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:02:26 +0530 Subject: [PATCH 06/19] fix: keep clamped traffic bounds monotonic across the antimeridian The previous fix wrapped the span correctly but normalized the returned west/east back into [-180, 180), which re-inverts the box whenever it crosses the antimeridian (west > east again). Downstream consumers (getBoundsCenter, boundsOverlap) assume west <= east and silently misbehave on an inverted box: the fetch center lands on the opposite side of the planet, and the overlap gate never skips a re-fetch. Drop the normalization and leave west/east unnormalized instead, so the box stays monotonic. The only remaining edge case (coordinates outside the conventional range reaching the Overpass query builder) is a visible, separate problem rather than a silently wrong center. Addresses review feedback on #392. --- src/data/trafficBounds.js | 24 ++++++++---------------- src/data/trafficBounds.test.mjs | 19 ++++++++++--------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 9f303785e..aed0053b7 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -129,17 +129,6 @@ function longitudeSpanDeg(west, east) { return raw >= 0 ? raw : raw + 360; } -/** - * Normalize a longitude into [-180, 180). Matches the wrap idiom already - * used by `destinationPoint()` above. - * - * @param {number} lon - Longitude in degrees. - * @returns {number} Longitude normalized to [-180, 180). - */ -function normalizeLonDeg(lon) { - return ((lon + 540) % 360) - 180; -} - /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * @@ -147,9 +136,12 @@ function normalizeLonDeg(lon) { * but centers the box on the derived look-at point instead of the view * rectangle's midpoint. Idempotent when `center` is the box's own midpoint. * - * Longitude is handled as a cyclic axis: the input span wraps correctly - * across the antimeridian, and the output west/east are normalized back into - * [-180, 180) instead of drifting outside the normal coordinate range. + * Longitude is handled as a cyclic axis for the span calculation, so the + * input span wraps correctly across the antimeridian. The output west/east + * are deliberately left unnormalized (may fall outside [-180, 180)) so that + * `west <= east` always holds — callers such as `getBoundsCenter` and + * `boundsOverlap` rely on that invariant and would silently misbehave on a + * normalized-but-inverted box (see #392 review). * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). @@ -163,7 +155,7 @@ export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) { return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: normalizeLonDeg(center.lon - lonSpan / 2), - east: normalizeLonDeg(center.lon + lonSpan / 2), + west: center.lon - lonSpan / 2, + east: center.lon + lonSpan / 2, }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index 63450dbb9..005123ddd 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -124,14 +124,15 @@ test('span clamp handles bounds crossing the antimeridian', () => { const result = clampBoundsAroundCenter(bounds, center, 0.05); - assert.ok(result.west >= -180 && result.west < 180); - assert.ok(result.east >= -180 && result.east < 180); + // west/east are deliberately left unnormalized (may exceed 180) so that + // west <= east always holds for downstream consumers (getBoundsCenter, + // boundsOverlap) that assume a monotonic box. See #392 review. + assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`); + assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12); - // Wrapped longitude span should remain about 0.04 degrees. - const span = - result.east >= result.west - ? result.east - result.west - : result.east - result.west + 360; - - assert.ok(Math.abs(span - 0.04) < 1e-12); + // The plain-mean center a caller like getBoundsCenter computes must land + // back on the antimeridian near the requested center, not ~180 degrees + // away on the opposite side of the planet. + const center2 = (result.west + result.east) / 2; + assert.ok(Math.abs(center2 - center.lon) < 1e-9, `center drifted: ${center2}`); }); From 0dcb02c6dabb8c1ec5dcf696f12595dc7966adaa Mon Sep 17 00:00:00 2001 From: cyberspace-cs Date: Thu, 17 Sep 2026 00:06:33 +0800 Subject: [PATCH 07/19] docs: add Contributing link to top navigation bar Make the contribution guide discoverable from the README's main navigation alongside Quick Start, Under the Hood, and Keys & Costs. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4224bacc0..304181c32 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ _“pretty cool”_ — [Brendan Eich](https://x.com/BrendanEich/status/20945920
-**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)** +**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys) · [Contributing](CONTRIBUTING.md)**
From 88f6e8bcf815b03f9a7bcfab20eed8763247e15d Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:41:06 +0100 Subject: [PATCH 08/19] fix: treat AIS "not available" speed and course as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over ground in 0.1-degree units, reserving the top code of each field for "not available": SOG 1023 reaches us as 102.3 knots and COG 3600 as 360 degrees. Both are finite, so numberValue passed them through as ordinary readings and every consumer — vessel cards, the HUD, voice analyst queries — treated a missing measurement as a real one. Observed on a live AISStream feed of 3,062 vessels: exactly 3 records at 102.3 knots with no reading anywhere between 40 and 102.3, and 404 records at exactly 360 degrees. One Dutch rescue boat moored at its station carried both sentinels at once, so an analyst summary reported a maximum speed of 102.3 knots — faster than any vessel afloat. Filter both at the store, where AISStream messages are decoded, so the sentinels never enter the cache. Heading already had this treatment through normalizedHeading with its own separate sentinel of 511. Co-Authored-By: Claude Opus 5 (1M context) --- server/providers/vessels/ais-store.js | 26 ++++++++++- src/data/aisStreamSentinels.test.mjs | 62 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/data/aisStreamSentinels.test.mjs diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 4a53464f6..297081b38 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) { imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), type: vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), - speed: numberValue(message.Sog ?? message.SOG), - course: numberValue(message.Cog ?? message.COG), + speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), + course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), last_position_UTC: normalizeAisTimestamp( metadata.time_utc ?? metadata.TimeUtc, @@ -277,6 +277,28 @@ function normalizedHeading(value) { return heading !== null && heading >= 0 && heading <= 360 ? heading : null; } +/** + * Speed over ground in knots, or null when the report carries AIS's "not + * available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means + * "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel + * and not a reading. It is finite, so every consumer would otherwise treat it + * as a genuine speed. + */ +function normalizedSpeedOverGround(value) { + const speed = numberValue(value); + return speed !== null && speed >= 0 && speed < 102.3 ? speed : null; +} + +/** + * Course over ground in degrees, or null when the report carries AIS's "not + * available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at + * 359.9; heading keeps its own separate sentinel of 511. + */ +function normalizedCourseOverGround(value) { + const course = numberValue(value); + return course !== null && course >= 0 && course < 360 ? course : null; +} + function normalizeAisTimestamp(value) { const text = stringValue(value); if (!text) return new Date().toISOString(); diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs new file mode 100644 index 000000000..4377c37c2 --- /dev/null +++ b/src/data/aisStreamSentinels.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { aisStreamRows, ingestAisStreamEnvelope } from '../../server/providers/vessels/ais-store.js'; + +// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over +// ground in 0.1-degree units, reserving the top code of each field for "not +// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees. +// Both are finite numbers, so they reach consumers as ordinary readings unless +// the store rejects them here. +const positionReport = (mmsi, body) => ({ + MessageType: 'PositionReport', + Message: { PositionReport: { UserID: Number(mmsi), ...body } }, + MetaData: { + MMSI: mmsi, + latitude: 53.40879, + longitude: 6.19741, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, +}); + +const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi); + +test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })), + true, + ); + assert.equal(rowFor('244060365').speed, null); +}); + +test('course over ground reported as unavailable is unknown, not 360 degrees', () => { + assert.equal( + ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })), + true, + ); + assert.equal(rowFor('245382000').course, null); +}); + +test('genuine speed and course readings survive the unavailable check', () => { + ingestAisStreamEnvelope( + positionReport('341260000', { Sog: 12.9, Cog: 74.6 }), + ); + const row = rowFor('341260000'); + assert.equal(row.speed, 12.9); + assert.equal(row.course, 74.6); +}); + +test('a stopped vessel keeps its zero speed and zero course', () => { + ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 })); + const row = rowFor('244374235'); + assert.equal(row.speed, 0); + assert.equal(row.course, 0); +}); + +test('the fastest encodable reading below the unavailable code is kept', () => { + ingestAisStreamEnvelope( + positionReport('244060366', { Sog: 102.2, Cog: 359.9 }), + ); + const row = rowFor('244060366'); + assert.equal(row.speed, 102.2); + assert.equal(row.course, 359.9); +}); From b0f5072347ff90937fce8c709d107528dadfc5e3 Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:41:06 +0100 Subject: [PATCH 09/19] docs: record the AIS unavailable-code handling CONTRIBUTING asks for docs/CURRENT-STATE.md and CHANGELOG.md in the same PR as a runtime behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..2b5622678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Report AIS speed and course that carry the standard "not available" code as + unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a + stopped vessel's zero and the highest encodable values, are unchanged. + - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5727f353..a8a313819 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +AIS encodes speed over ground in 0.1-knot units and course over ground in +0.1-degree units, reserving the top code of each field for "not available", so +those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, +and vessel cards, the HUD and analyst queries show a missing measurement rather +than a reading. Heading keeps its existing separate check for its own sentinel. +Genuine readings are unchanged, including a stopped vessel's zero and the +highest speed and course the fields can encode. + Vessel snapshot completeness is separate from freshness. A current snapshot with rejected or duplicate records shows PARTIAL with accepted/received counts; stale or unknown freshness and transport failures retain their warnings. Partial From 75869d0c103794e4c60f879b6a9f8ca531634694 Mon Sep 17 00:00:00 2001 From: Theerayut Boonkird Date: Fri, 18 Sep 2026 00:43:02 +0100 Subject: [PATCH 10/19] chore: adopt the new AIS test into the explicit formatting scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/format.mjs skips *.test.mjs during runtime discovery, so a test added under a discovered root is never seen by format:check until it is listed in scripts/format-scope.json — which is what CODE-BOUNDARIES.md means by adding tests to the explicit list as they are adopted. The file went in unformatted and the check stayed green, hiding it. Listed and formatted; format:check now covers 917 files instead of 916. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/format-scope.json | 3 ++- src/data/aisStreamSentinels.test.mjs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..dc623c0c0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -743,5 +743,6 @@ "src/director/interactions/interactions.test.mjs", "scripts/qa-director-interactions.mjs", "src/director/sharing/sharing.test.mjs", - "scripts/qa-director-sharing.mjs" + "scripts/qa-director-sharing.mjs", + "src/data/aisStreamSentinels.test.mjs" ] diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 4377c37c2..6595b795e 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { aisStreamRows, ingestAisStreamEnvelope } from '../../server/providers/vessels/ais-store.js'; +import { + aisStreamRows, + ingestAisStreamEnvelope, +} from '../../server/providers/vessels/ais-store.js'; // ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over // ground in 0.1-degree units, reserving the top code of each field for "not From 59b2637fb7ae9c8ced6af5dd08cee7ced9b5a0a0 Mon Sep 17 00:00:00 2001 From: Uday S Date: Fri, 18 Sep 2026 20:04:53 -0400 Subject: [PATCH 11/19] fix: render native select option lists in the dark palette --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 6 ++++++ src/ui/styles/foundation.css | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..de3b91cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Render native `` controls declare a dark colour scheme and explicit option +colours, so the browser-painted option list matches the panel it drops out of. +The scheme is declared on the controls rather than on the document root, so +hand-tuned scrollbar and text-input skins are unchanged. Applies to the HUD +layout, Scenes, CCTV camera, Radio filter and Draw colour menus. + Vessel snapshot completeness is separate from freshness. A current snapshot with rejected or duplicate records shows PARTIAL with accepted/received counts; stale or unknown freshness and transport failures retain their warnings. Partial diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 828eb9922..2d374b2c6 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -14,6 +14,8 @@ --text-primary: #e8eaed; --text-secondary: rgba(232, 234, 237, 0.5); --text-dim: rgba(232, 234, 237, 0.3); + /* Opaque surface for browser-painted menus, which cannot use --glass-bg. */ + --menu-bg: #12121c; --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --panel-radius: 16px; @@ -69,6 +71,26 @@ outline-offset: 2px; } +/* Native `