diff --git a/.env.example b/.env.example index 5bc02df9c..1949c48f4 100644 --- a/.env.example +++ b/.env.example @@ -129,18 +129,20 @@ AISSTREAM_API_KEY= # 0 disables the silence watch entirely. # AISSTREAM_SILENCE_TIMEOUT_MS=120000 -# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile -# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: +# Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000 +# Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card +# required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY: # the browser fetches same-origin /api/tomtom/* and never sees the key. # Keyless fallback: the traffic layer runs its built-in simulation (white dots, # hardcoded per-road-class speeds) — no key required for the layer to work. # TOMTOM_API_KEY= -# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a +# Optional soft cap on upstream tile fetches per UTC day (default 6000) — a # configurable application safety ceiling, not a guarantee of staying within # TomTom's free allowance (currently 200K tile requests/month: -# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale -# tiles instead of hitting upstream. -# TOMTOM_DAILY_TILE_BUDGET=40000 +# https://docs.tomtom.com/pricing/). 6000/day keeps a 31-day month at 186K, +# inside that allowance. Over the cap the proxy serves cached/stale tiles +# instead of hitting upstream. +# TOMTOM_DAILY_TILE_BUDGET=6000 # Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset # unless you're customizing the camera source pack. diff --git a/.gitignore b/.gitignore index 491a82c15..1a64fff39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ node_modules dist +# Credential files. `.env` alone is not enough: Vite's loadEnv (see +# server/standalone/vite.config.js) and scripts/setup-doctor.mjs both read the +# full dotenv ladder — .env, .env.local, .env. and .env..local — +# so a key placed in any of them is live for the app. Only .env was ignored, +# leaving the other three committable. .env.example is the tracked template. .env +.env.* +!.env.example *.log screenshots/ qa-shots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..da600f5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # 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. + +- 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 @@ -3543,6 +3557,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 OAuth mode without a client pair report keyless anonymous access (rate-limited); a complete OAuth pair retains the existing presence-only capability wording. Basic and auto modes report the selected mode without guessing which credentials runtime will accept. 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) @@ -3558,6 +3573,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` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, 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. @@ -3567,6 +3583,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 @@ -3800,7 +3817,8 @@ easier to meet (detection is now on more often), but does not create it. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy - (`.gev-cache/tomtom/`, 120 s TTL, `TOMTOM_DAILY_TILE_BUDGET` default 40k/day), + (`.gev-cache/tomtom/`, 120 s TTL, `TOMTOM_DAILY_TILE_BUDGET` default 6k/day, + sized so a 31-day month stays inside TomTom's 200K/month free allowance), decoded client-side (`flowTiles.js`), matched onto Overpass roads (`flowMatch.js`), and rendered as green/amber/red dot color + speed/density scaling (`trafficFlowStyle.js`); closures spawn no dots; unmatched roads stay diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..2ed4f1081 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -495,6 +495,7 @@ "src/layers/cctv/source.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", + "src/data/dataCredits.test.mjs", "src/layers/alpr/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", @@ -743,5 +744,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/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 51b78be7d..954369030 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -8,6 +8,8 @@ import { projectRoot } from './project-root.mjs'; import { selectMapStartupRoute } from '../src/mapStartup.js'; const ROOT = projectRoot(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']] }, @@ -101,6 +103,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', [ @@ -128,8 +145,25 @@ 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 requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); + const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) + ? requestedOpenSkyMode + : OPENSKY_AUTH_MODE_DEFAULT; + const flights = openSkyMode === 'anon' + ? 'OpenSky keyless anonymous access (rate-limited)' + : openSkyMode === 'basic' + ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + : openSkyMode === 'auto' + ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '', @@ -140,9 +174,7 @@ 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') - ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' - : 'OpenSky OAuth credentials not configured', + flights, 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', @@ -173,7 +205,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment : { available: false, version: null }, dependenciesInstalled, credentials, - capabilities: buildCapabilitySummary(credentials), + capabilities: buildCapabilitySummary(credentials, { + openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + }), }; } diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 5f601532f..85cece004 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -308,11 +308,21 @@ 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. + // + // 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', 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/common/request.js b/server/providers/common/request.js index ab261d779..93bbbaa85 100644 --- a/server/providers/common/request.js +++ b/server/providers/common/request.js @@ -17,17 +17,29 @@ async function readRequestBodyCapped(req, maxBytes) { function readRequestBody(req, maxBytes = 1024 * 1024) { return new Promise((resolve, reject) => { let total = 0; + let bodyTooLarge = false; const chunks = []; req.on('data', (chunk) => { + // Keep draining after the cap without retaining more bytes. Destroying + // the request also destroys its socket, so the handler cannot deliver + // the fixed error response it constructs for BODY_TOO_LARGE. + if (bodyTooLarge) return; total += chunk.length; if (total > maxBytes) { - reject(new Error(`Request body exceeds ${maxBytes} bytes`)); - req.destroy(); + // Same marker readRequestBodyCapped sets, so a caller can tell an + // oversized body from a genuine failure without matching on text. + bodyTooLarge = true; + chunks.length = 0; + const error = new Error(`Request body exceeds ${maxBytes} bytes`); + error.code = 'BODY_TOO_LARGE'; + reject(error); return; } chunks.push(chunk); }); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('end', () => { + if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8')); + }); req.on('error', reject); }); } diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index f7dd42154..6d781a31c 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -1,16 +1,65 @@ 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) { + // Node does not replace an existing destination on Windows. Remove the + // retained generation explicitly so every rotation behaves the same on + // supported platforms rather than failing after the first 64 MB. + await fsp.rm(`${logFile}.1`, { force: true }); + 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 +68,19 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { return; } + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '60', + }); + 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 +89,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/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..b4dfb4b5a 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -132,26 +132,35 @@ function createRealtimeTokenHandler({ }); const body = await response.text(); res.statusCode = response.status; - res.setHeader( - 'Content-Type', - response.headers.get('content-type') || 'application/json', - ); // Which tier/model this secret was actually minted for. The upstream - // body is passed through untouched (the client parses it verbatim), so - // these headers are the authoritative echo — including the case where a - // bogus ?tier= was silently downgraded to standard. + // success body is passed through untouched (the client parses it + // verbatim), so these headers are the authoritative echo — including the + // case where a bogus ?tier= was silently downgraded to standard. res.setHeader('X-GEV-Voice-Tier', tier); res.setHeader('X-GEV-Voice-Model', model); if (requestedTier && !isKnownVoiceTier(requestedTier)) { res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); } + if (!response.ok) { + console.warn(`[realtime-token] upstream HTTP ${response.status}`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ error: 'Failed to create Realtime token' })); + return; + } + res.setHeader( + 'Content-Type', + response.headers.get('content-type') || 'application/json', + ); 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/server/providers/traffic.js b/server/providers/traffic.js index fda118753..2a3c2e60a 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -23,9 +23,16 @@ import { * Budget governor (mirrors the OpenSky credit-governor philosophy — last-good * data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json, * keyed by UTC date, reset on day change) counts upstream fetch attempts - * against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free - * tier's ~50k/day). Over the cap the proxy serves stale tiles when available, - * else 429 {error:'budget'}. + * against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy + * serves stale tiles when available, else 429 {error:'budget'}. + * + * The default is derived from TomTom's published free allowance, which is + * granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests + * per month (https://docs.tomtom.com/pricing/). The previous 40,000/day + * default — and the "~50k/day" figure this comment used to cite — exhausted + * a month's entire allowance in five days, leaving the traffic layer dead + * for the rest of the billing period. 6,000/day keeps a full 31-day month + * inside the allowance (186,000) with headroom to spare. * * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} @@ -37,7 +44,8 @@ export function tomtomProxy() { const TILE_TTL_MS = 120_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom'); const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json'); - const DEFAULT_DAILY_BUDGET = 40000; + // 200,000/month free tier / 31 days = 6,451. Rounded down for headroom. + const DEFAULT_DAILY_BUDGET = 6000; const MEM_MAX_ENTRIES = 256; const UPSTREAM_TIMEOUT_MS = 15000; 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..6595b795e --- /dev/null +++ b/src/data/aisStreamSentinels.test.mjs @@ -0,0 +1,65 @@ +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); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..dde7d3b62 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -42,6 +42,17 @@ export const DATA_CREDITS = [ 'adsb.lol ' + '(ODbL 1.0)', }, + { + key: 'adsbdb', + html: + 'Aircraft type, registration & flight routes: ' + + 'adsbdb ' + + '· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' + + 'Guillaume Michel · route data is the work of ' + + 'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' + + 'copied, published, or incorporated into other databases without the ' + + 'explicit permission of David J Taylor, Edinburgh', + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs new file mode 100644 index 000000000..5172867c4 --- /dev/null +++ b/src/data/dataCredits.test.mjs @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DATA_CREDITS } from './dataCredits.js'; + +test('every credit carries a unique key and some markup to render', () => { + const keys = DATA_CREDITS.map((entry) => entry.key); + assert.equal( + new Set(keys).size, + keys.length, + 'a duplicate key would silently shadow one provider’s credit', + ); + for (const entry of DATA_CREDITS) { + assert.ok(entry.key, 'a credit without a key cannot be registered'); + assert.ok( + entry.html && entry.html.trim().length > 0, + `credit ${entry.key} has nothing to show`, + ); + } +}); + +test('adsbdb is credited and carries its published route-data restriction', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb'); + assert.ok( + credit, + 'adsbdb supplies aircraft type and routes and must be credited', + ); + // adsbdb publishes this restriction for its route data. Pin the provider's + // credits and restriction here so a later edit cannot silently remove them. + assert.match(credit.html, /David Taylor, Edinburgh/); + assert.match(credit.html, /Jim Mason, Glasgow/); + assert.match( + credit.html, + /may not be\s+copied, published, or incorporated into other databases/, + ); + assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/); + assert.match(credit.html, /PlaneBase/); + assert.match(credit.html, /Guillaume Michel/); + assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/); +}); diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 4bd80ff32..ff5142133 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -123,12 +123,51 @@ export function deriveFetchCenter({ 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; +} + +/** Keep a monotonic longitude range inside the traffic source's legal domain. */ +function boundedLongitudeRange(center, span) { + let west = center - span / 2; + let east = center + span / 2; + if (east > 180) { + west -= east - 180; + east = 180; + } + if (west < -180) { + east += -180 - west; + west = -180; + } + return { west, east }; +} + /** * Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`. * * Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km) * 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. + * rectangle's midpoint. At the antimeridian the whole box shifts just far + * enough to remain inside [-180, 180], because the road source accepts one + * monotonic Overpass bounding box. Idempotent when `center` is the box's own + * midpoint. + * + * Longitude is handled as a cyclic axis for the span calculation, so the + * input span wraps correctly across the antimeridian. Output remains + * monotonic because callers such as `getBoundsCenter` and `boundsOverlap` + * rely on `west <= east`. * * @param {{south:number, west:number, north:number, east:number}} bounds * Source bounds (span donor). @@ -138,11 +177,14 @@ export function deriveFetchCenter({ */ 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, + ); + const longitude = boundedLongitudeRange(center.lon, lonSpan); return { south: center.lat - latSpan / 2, north: center.lat + latSpan / 2, - west: center.lon - lonSpan / 2, - east: center.lon + lonSpan / 2, + ...longitude, }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..0ff38628d 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -108,3 +108,19 @@ 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 keeps antimeridian bounds monotonic and request-safe', () => { + for (const centerLon of [179.99, -179.99]) { + const result = clampBoundsAroundCenter({ + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }, { lat: 0, lon: centerLon }, 0.05); + + assert.ok(result.west >= -180 && result.east <= 180); + 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); + assert.ok(result.west <= centerLon && centerLon <= result.east); + } +}); diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 763dfe7bf..8047ddbbd 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { createTrafficSource } from './source.js'; +import { clampBoundsAroundCenter } from '../../data/trafficBounds.js'; const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 }; const fixture = readFileSync( new URL( @@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async () /residential/, ); }); +test('antimeridian clamps produce road bounds accepted on either side', async () => { + const calls = []; + const source = createTrafficSource({ + fetchImpl: async (...args) => { + calls.push(args); + return new Response('{"elements":[]}'); + }, + }); + for (const centerLon of [179.99, -179.99]) { + const clamped = clampBoundsAroundCenter( + { + south: -0.02, + north: 0.02, + west: 179.98, + east: -179.98, + }, + { lat: 0, lon: centerLon }, + ); + await source.requestRoads(clamped); + } + assert.equal(calls.length, 2); +}); test('malformed availability is an unavailable source rather than a keyless response', async () => { const source = createTrafficSource({ fetchImpl: async () => new Response('{}'), diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 857a96c78..c66d9e686 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,67 @@ 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 reports Basic mode without inferring runtime auth from OAuth credentials', () => { + const oauthCredentials = { + OPENSKY_CLIENT_ID: { configured: true }, + OPENSKY_CLIENT_SECRET: { configured: true }, + }; + for (const credentials of [{}, oauthCredentials]) { + assert.equal( + buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, + 'OpenSky Basic mode selected (credential presence and validity not verified)', + ); + } +}); + +test('doctor reports auto mode without assuming its eventual credential choice', () => { + const oauthCredentials = { + OPENSKY_CLIENT_ID: { configured: true }, + OPENSKY_CLIENT_SECRET: { configured: true }, + }; + for (const credentials of [{}, oauthCredentials]) { + assert.equal( + buildCapabilitySummary(credentials, { openSkyAuthMode: 'auto' }).flights, + 'OpenSky auto mode selected (runtime credential choice and validity not verified)', + ); + } +}); + test('doctor describes the credential ladder without exposing values', () => { const credentials = { GOOGLE_MAPS_API_KEY: { configured: false }, @@ -163,7 +225,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, diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index d7f2e4ba3..26eebfa08 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createServer } from 'node:http'; import { Readable } from 'node:stream'; import { mkdtempSync, @@ -279,7 +280,157 @@ test('Realtime service configuration selects compatible endpoint/model without f url: '/?tier=arbitrary-model&model=other', }); assert.equal(response.status, 200); + assert.deepEqual(response.json(), { value: 'short-lived-fixture' }); assert.equal(response.headers['x-gev-voice-model'], 'configured-model'); 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); + + // An HTTP error from the client-secret endpoint carries the same upstream + // detail, while a successful response must still pass the ephemeral secret. + t.mock.restoreAll(); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ error: { message: leak } }, { status: 429 }), + ); + const rejectedToken = await request( + install(openAiRealtimeProxy()).get('/api/realtime/token'), + ); + assert.equal(rejectedToken.status, 429); + assert.equal( + rejectedToken.headers['content-type'], + 'application/json; charset=utf-8', + ); + assert.deepEqual(rejectedToken.json(), { + error: 'Failed to create Realtime token', + }); + assert.equal(rejectedToken.body.includes('req_fixture_1234'), false); + assert.equal(rejectedToken.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); +}); + +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'], '60'); + 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('an oversized debug-log request receives the fixed error response', async (t) => { + const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get( + '/api/realtime/debug-log', + ); + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const response = await fetch( + `http://127.0.0.1:${server.address().port}/api/realtime/debug-log`, + { method: 'POST', body: 'x'.repeat(8 * 1024 * 1024 + 1) }, + ); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'Failed to write Realtime debug log', + }); +}); + +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); + // Seventy-two records force two rotations. The second one replaces an + // existing `.1`, which requires an explicit removal on Windows. + for (let n = 0; n < 72; 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 ~75 MB in one file. + assert.ok(live + previous < 64 * 1024 * 1024); + assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained'); +}); 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); +}); 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 `