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); +});