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 1/3] 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 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 2/3] 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 2608f187d36d7bb8f9a104c821e34740ec6fc86e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 20 Sep 2026 17:45:43 -0700 Subject: [PATCH 3/3] fix: sanitize rejected Realtime token responses --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 +- server/providers/openai/realtime.js | 20 +++++++++++++------- src/tooling/localServices.test.mjs | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c15563c..99a421a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,7 +133,7 @@ - Expose portable radio, camera-type and regional source helpers; keep HTTP transport separate from record normalization. -- 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. +- 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 passed through non-success response bodies and echoed JS errors, which can expose upstream details; 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e41946ecd..570d99796 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3573,7 +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` 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. +- 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. diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 74a2eccb9..b4dfb4b5a 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -132,19 +132,25 @@ 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 { // For a network fault this was a resolver message naming the upstream diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 395dd4f09..ce9d000b6 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -279,6 +279,7 @@ 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/); @@ -303,6 +304,26 @@ test('OpenAI routes answer generically when the upstream or the request fails', 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 () => {