From 2fd1b4323c7b3a787b4d8de675d8279b2a29d8e3 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:06:34 -0400 Subject: [PATCH] fix(tools): identify historical camera fallback imagery (#1118) The CCTV frame proxy marks a Street View fallback with X-CCTV-Source, but the portable source dropped the header and get_cctv_snapshot always reported a current view credited to the camera's agency. The source now keeps the recognised provenance, and the tool labels Street View frames as historical Google imagery, refuses synthetic placeholders, and reports unknown provenance without claiming a current view. --- .../1118-cctv-snapshot-fallback-provenance.md | 3 + docs/CURRENT-STATE.md | 8 + docs/TOOLS.md | 2 +- src/layers/cctv/source.js | 15 +- src/layers/cctv/source.test.mjs | 23 +++ src/tools/queries/media.js | 30 ++- src/tools/queries/media.test.mjs | 185 +++++++++++++++++- 7 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1118-cctv-snapshot-fallback-provenance.md diff --git a/changelog.d/1118-cctv-snapshot-fallback-provenance.md b/changelog.d/1118-cctv-snapshot-fallback-provenance.md new file mode 100644 index 000000000..648891785 --- /dev/null +++ b/changelog.d/1118-cctv-snapshot-fallback-provenance.md @@ -0,0 +1,3 @@ +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5671f41ec..9163a5a21 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1370,6 +1370,14 @@ outside its promise, so an unusual mirror status cannot crash the server. Node-only package entries expose both provider factories. Routes, payloads, fallback behavior and existing dev/preview hook registration are preserved; browser rendering is unchanged. +The portable frame source retains the proxy's known `X-CCTV-Source` kind. Camera +snapshot tools label Street View fallback imagery as historical and credit Google, +while upstream frames retain their current-view label and catalog attribution. +Synthetic placeholders are unavailable as snapshots. Injected frame sources may +supply `sourceKind`; missing or unknown provenance keeps `current_view` null and +does not claim the image is current. The `current_view` field distinguishes camera-feed +provenance from historical fallback imagery; it does not measure capture time or +image freshness. ## CCTV missing numeric fields diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..3d31fe78b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -273,7 +273,7 @@ and the development server's internal routes. See SECURITY.md. | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first | | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | -| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | +| `get_cctv_snapshot` | `cctv` | An image from one camera, returned as image content; Street View fallbacks are labelled historical | | `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° | | `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index 0dddce1b4..3b628954b 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -3,6 +3,12 @@ import { FRAME_ENDPOINT, MEDIA_ENDPOINT, } from './sourcePolicy.js'; +const FRAME_SOURCE_KINDS = new Set([ + 'upstream-image', + 'hls-pull', + 'streetview', + 'synthetic', +]); function safeNumber(value, fallback = NaN) { const n = Number(value); return Number.isFinite(n) ? n : fallback; @@ -51,7 +57,7 @@ export function createCctvSource({ }, getFrameUrl: frameUrlFor, getMediaUrl: mediaUrlFor, - /** Read one current frame through the registered frame endpoint. */ + /** Read a registered frame and its provenance, including fallback frames. */ async getFrame(camera, { signal } = {}) { signal?.throwIfAborted(); const response = await fetchImpl(frameUrlFor(camera), { @@ -62,7 +68,12 @@ export function createCctvSource({ const bytes = new Uint8Array(await response.arrayBuffer()); signal?.throwIfAborted(); const type = response.headers.get('content-type') || ''; - return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + const sourceKind = response.headers.get('x-cctv-source'); + return { + contentType: type.split(';')[0].trim().toLowerCase(), + bytes, + sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null, + }; }, }; } diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index 0103c03de..a4b276d21 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => { }); await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/); }); + +test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => { + for (const kind of [ + 'upstream-image', + 'hls-pull', + 'streetview', + 'synthetic', + 'other', + null, + ]) { + const source = createCctvSource({ + fetchImpl: async () => + new Response(new Uint8Array([1]), { + headers: { + 'Content-Type': 'image/png', + ...(kind ? { 'X-CCTV-Source': kind } : {}), + }, + }), + }); + const frame = await source.getFrame(camera); + assert.equal(frame.sourceKind, kind === 'other' ? null : kind); + } +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 4879d57c5..a03e88bb4 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({ export const getCctvSnapshot = defineTool({ name: 'get_cctv_snapshot', - title: 'Current camera view', + title: 'Camera snapshot', description: - 'The current image from one public camera, by the id from find_cctv_cameras.', + 'An image from one public camera, by the id from find_cctv_cameras. ' + + 'Street View fallback imagery is historical, not the current camera view; ' + + 'unknown frame provenance is explicit.', inputSchema: { type: 'object', properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } }, @@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({ `No camera has id ${args.camera_id}; use find_cctv_cameras first`, ); const frame = await services.cctv.getFrame(camera, { signal }); - if (!FRAME_TYPES.has(frame.contentType)) + if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType)) throw new ToolError( 'unavailable', `Camera ${camera.name || camera.id} has no still image right now`, @@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({ 'The camera image is too large to return', ); const name = text(camera.name) || camera.id; + const fallback = frame.sourceKind === 'streetview'; + const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind); + const credit = fallback ? 'Google Street View' : text(camera.credit); + const label = fallback + ? 'Street View fallback for' + : current + ? 'Current view from' + : 'Image from'; return { - summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`, + summary: + `${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` + + (fallback + ? ' Historical imagery; the current camera frame is unavailable.' + : current + ? '' + : ' Frame provenance is unknown.'), data: { view: suggestView(services, { point: { lat: camera.lat, lon: camera.lon }, @@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({ id: camera.id, name, city: text(camera.city), - provider: text(camera.provider), - credit: text(camera.credit), + provider: fallback ? 'Google Street View' : text(camera.provider), + credit, + source_kind: frame.sourceKind ?? null, + current_view: current ? true : fallback ? false : null, mime_type: frame.contentType, bytes: frame.bytes.byteLength, }, diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a024191e7..28c037a57 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -30,7 +30,13 @@ const cameras = [ { id: 'no-position', name: 'Unplaced' }, ]; const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({ +const cctv = ( + frame = { + contentType: 'image/png', + bytes: PNG, + sourceKind: 'upstream-image', + }, +) => ({ frames: [], async getCatalog() { return { sources: cameras }; @@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => { }); }); +test('mounted camera fallback provenance survives the source and MCP image result', async (t) => { + const { cctvProxy } = await import('../../../server/providers/cctv.js'); + const { createCctvSource } = await import('../../layers/cctv/source.js'); + const camera = { + id: 'fixture-camera', + name: 'Fixture crossing', + city: 'Fixture City', + provider: 'Fixture transport agency', + credit: 'Fixture transport agency', + lat: 30.2685, + lon: -97.7425, + headingDeg: 180, + fovDeg: 60, + url: 'https://camera.example/current.png', + feedType: 'image', + }; + const environment = { + CCTV_SOURCES_JSON: JSON.stringify([camera]), + CCTV_PREFER_AUSTIN: '0', + CCTV_FORCE_AUSTIN: '0', + CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json', + GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key', + GOOGLE_MAPS_API_KEY: '', + }; + for (const [name, value] of Object.entries(environment)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + process.env[name] = value; + } + t.mock.method(console, 'warn', () => {}); + let upstreamAvailable = false; + const calls = []; + t.mock.method(globalThis, 'fetch', async (raw) => { + const url = new URL(raw); + calls.push(url.origin); + if (url.origin === 'https://camera.example') + return upstreamAvailable + ? new Response(PNG, { headers: { 'Content-Type': 'image/png' } }) + : new Response(null, { status: 503 }); + assert.equal( + url.origin, + 'https://maps.googleapis.com', + 'no actual upstream is contacted', + ); + assert.equal(url.searchParams.get('key'), 'fixture-key'); + return new Response(PNG, { headers: { 'Content-Type': 'image/png' } }); + }); + let middleware; + cctvProxy().configureServer({ + middlewares: { + use(route, handler) { + assert.equal(route, '/api/cctv'); + middleware = handler; + }, + }, + }); + const source = createCctvSource({ + fetchImpl: async (path) => { + assert.ok(path.startsWith('/api/cctv/')); + const response = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = body; + }, + }; + await middleware( + { url: path.slice('/api/cctv'.length), method: 'GET', headers: {} }, + response, + ); + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + }, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const fallback = await catalog.call('get_cctv_snapshot', { + camera_id: camera.id, + }); + assert.equal(fallback.data.source_kind, 'streetview'); + assert.equal(fallback.data.current_view, false); + assert.equal(fallback.data.credit, 'Google Street View'); + assert.match(fallback.summary, /Street View fallback/); + assert.match(fallback.summary, /Historical imagery/); + assert.doesNotMatch( + fallback.summary, + /Current view|courtesy of Fixture transport agency/, + ); + assert.equal( + fallback.images.length, + 1, + 'the historical image stays available with its caveat', + ); + assert.deepEqual(calls, [ + 'https://camera.example', + 'https://maps.googleapis.com', + ]); + upstreamAvailable = true; + const current = await catalog.call('get_cctv_snapshot', { + camera_id: camera.id, + }); + assert.equal(current.data.source_kind, 'upstream-image'); + assert.equal(current.data.current_view, true); + assert.equal(current.data.credit, camera.credit); + assert.match(current.summary, /^Current view from/); + const server = createMcpServer({ catalog, name: 'test', version: '1' }); + upstreamAvailable = false; + const result = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } }, + }); + assert.ok( + result.result.content.some( + (entry) => entry.type === 'text' && /Historical imagery/.test(entry.text), + ), + ); + assert.ok(result.result.content.some((entry) => entry.type === 'image')); + process.env.GOOGLE_MAPS_SERVER_API_KEY = ''; + const placeholder = await source.getFrame(camera); + assert.equal(placeholder.sourceKind, 'synthetic'); + assert.equal(placeholder.contentType, 'image/svg+xml'); + await assert.rejects( + catalog.call('get_cctv_snapshot', { camera_id: camera.id }), + (error) => error.code === 'unavailable', + ); + const unavailable = await server.handle({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } }, + }); + assert.equal(unavailable.result.isError, true); + assert.ok( + !unavailable.result.content.some((entry) => entry.type === 'image'), + ); +}); + +test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => { + const synthetic = composeCatalog({ + tools: coreTools, + services: { + cctv: cctv({ + contentType: 'image/png', + bytes: PNG, + sourceKind: 'synthetic', + }), + }, + }); + await assert.rejects( + synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }), + (error) => error.code === 'unavailable', + ); + const unknown = await composeCatalog({ + tools: coreTools, + services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) }, + }).call('get_cctv_snapshot', { camera_id: 'austin-1' }); + assert.equal(unknown.data.current_view, null); + assert.equal( + unknown.data.credit, + cameras[0].credit, + 'catalog attribution is preserved', + ); + assert.match(unknown.summary, /provenance is unknown/); + assert.doesNotMatch(unknown.summary, /Current view/); +}); + test('unknown cameras, video-only feeds and oversized frames are reported', async () => { const catalog = (frame) => composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });