Merge branch 'main' into feature/geo-uri-search

This commit is contained in:
Sameh Khamis committed 2026-10-16 17:06:38 -07:00
commit 0c3723e930
12 files changed
+348 -17

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients. - **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. - **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
@@ -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).
@@ -0,0 +1,4 @@
- Cross-site browser requests can no longer spend the TomTom traffic-tile
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
cache, and upstream work; same-origin app requests and non-browser local
callers remain supported (Sunil56224972, #973).
+12 -1
View File
@@ -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, Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved; fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged. 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 ## CCTV missing numeric fields
@@ -1392,7 +1400,10 @@ plugin order, server-key selection, validation, disk caches, budgets, retries
and stale/error responses remain unchanged. Each has a Node-only package entry and stale/error responses remain unchanged. Each has a Node-only package entry
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
math and GBFS source rules are available under `gods-eye-view/sources/`. math and GBFS source rules are available under `gods-eye-view/sources/`.
The browser layers and their rendering remain in their existing modules. The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
status, cache or upstream work, refusing cross-site browser requests while
preserving same-origin app requests and non-browser local callers. The browser
layers and their rendering remain in their existing modules.
GBFS null or empty availability counts retain an unknown value through station GBFS null or empty availability counts retain an unknown value through station
parsing and rendering; they use the neutral station color rather than reporting parsing and rendering; they use the neutral station color rather than reporting
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
+1 -1
View File
@@ -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 | | `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 | | `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 | | `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_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 | | `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) | | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
+3 -1
View File
@@ -131,7 +131,9 @@
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/tomtomTiles.js" "src/data/tomtomTiles.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
], ],
"external": [] "external": []
}, },
+10
View File
@@ -8,6 +8,7 @@ import {
normalizeBudget as normalizeTomTomBudget, normalizeBudget as normalizeTomTomBudget,
isOverBudget as isTomTomOverBudget, isOverBudget as isTomTomOverBudget,
} from '../../src/data/tomtomTiles.js'; } from '../../src/data/tomtomTiles.js';
import { admitSameSite } from './common/same-site.js';
/** /**
* TomTom traffic-flow vector-tile proxy with a daily budget governor. * TomTom traffic-flow vector-tile proxy with a daily budget governor.
@@ -35,6 +36,13 @@ import {
* for the rest of the billing period. 6,000/day keeps a full 31-day month * 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. * inside the allowance (186,000) with headroom to spare.
* *
* Cross-site gate: both routes refuse cross-site browser requests with 403
* (the gate Mapillary, OpenAI and Places already use). Every uncached tile is
* a billed upstream attempt counted against the persistent daily budget, so
* without the gate any web page the user visits could fire `<img>` requests
* at distinct tiles, spend the TomTom quota and leave the traffic layer on
* 429 budget for the rest of the UTC day.
*
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'} * status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
* without touching upstream — the traffic layer then stays in simulation mode. * without touching upstream — the traffic layer then stays in simulation mode.
@@ -161,6 +169,8 @@ export function tomtomProxy() {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/tomtom', async (req, res) => { server.middlewares.use('/api/tomtom', async (req, res) => {
// Gate first: a cross-site <img>/fetch must not reach the budget counter.
if (admitSameSite(req, res)) return;
// Sanitized responses only (proxy/security baseline): no upstream // Sanitized responses only (proxy/security baseline): no upstream
// error details, and never echo the key or the upstream URL. // error details, and never echo the key or the upstream URL.
const sendJson = (status, obj, extraHeaders = {}) => { const sendJson = (status, obj, extraHeaders = {}) => {
+13 -2
View File
@@ -3,6 +3,12 @@ import {
FRAME_ENDPOINT, FRAME_ENDPOINT,
MEDIA_ENDPOINT, MEDIA_ENDPOINT,
} from './sourcePolicy.js'; } from './sourcePolicy.js';
const FRAME_SOURCE_KINDS = new Set([
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
]);
function safeNumber(value, fallback = NaN) { function safeNumber(value, fallback = NaN) {
const n = Number(value); const n = Number(value);
return Number.isFinite(n) ? n : fallback; return Number.isFinite(n) ? n : fallback;
@@ -51,7 +57,7 @@ export function createCctvSource({
}, },
getFrameUrl: frameUrlFor, getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor, 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 } = {}) { async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted(); signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), { const response = await fetchImpl(frameUrlFor(camera), {
@@ -62,7 +68,12 @@ export function createCctvSource({
const bytes = new Uint8Array(await response.arrayBuffer()); const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted(); signal?.throwIfAborted();
const type = response.headers.get('content-type') || ''; 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,
};
}, },
}; };
} }
+23
View File
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
}); });
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/); 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);
}
});
+70 -4
View File
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs'; import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
import { localProviderPlugins } from '../../server/providers/local.js'; import { localProviderPlugins } from '../../server/providers/local.js';
function install(plugin) { function install(plugin, hook = 'configureServer') {
const routes = new Map(); const routes = new Map();
plugin.configureServer({ assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
plugin[hook]({
middlewares: { middlewares: {
use(route, handler) { use(route, handler) {
routes.set(route, handler); routes.set(route, handler);
@@ -17,9 +18,16 @@ function install(plugin) {
}, },
}); });
assert.equal(routes.size, 1); assert.equal(routes.size, 1);
return async (url = '/', method = 'GET') => { return async (url = '/', method = 'GET', headers = {}) => {
const res = { const res = {
headersSent: false, headersSent: false,
headers: {},
set statusCode(status) {
this.status = status;
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) { writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true }); Object.assign(this, { status, headers, headersSent: true });
}, },
@@ -27,7 +35,7 @@ function install(plugin) {
this.body = body; this.body = body;
}, },
}; };
await [...routes.values()][0]({ url, method }, res); await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
return res; return res;
}; };
} }
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(calls, 2); assert.equal(calls, 2);
}); });
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response(new Uint8Array([1, 2, 3]));
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
];
for (const [mode, hook] of [
['dev', 'configureServer'],
['preview', 'configurePreviewServer'],
]) {
const request = install(tomtomProxy(), hook);
// Each refused shape asks for distinct uncached tiles: without the gate
// every one would be a billed attempt counted against the daily budget.
let x = 0;
for (const headers of refused)
for (let i = 0; i < 2; i++) {
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
}
assert.equal(
(await request('/status', 'GET', refused[0])).status,
403,
`${mode}: status is gated too`,
);
assert.equal(
calls,
mode === 'dev' ? 0 : 2,
`${mode}: no refused upstream work`,
);
const app = { host, 'sec-fetch-site': 'same-origin' };
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
// The app itself and loopback tools (no browser headers) still get tiles.
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
assert.equal(tile.status, 200);
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
assert.equal(
(
await request('/flow/8/1/1.pbf', 'GET', {
host,
origin: `http://${host}`,
})
).headers['x-tomtom-cache'],
'HIT',
);
assert.equal(calls, mode === 'dev' ? 2 : 4);
}
});
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => { test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' }); isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
const bodies = []; const bodies = [];
+24 -6
View File
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
export const getCctvSnapshot = defineTool({ export const getCctvSnapshot = defineTool({
name: 'get_cctv_snapshot', name: 'get_cctv_snapshot',
title: 'Current camera view', title: 'Camera snapshot',
description: 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: { inputSchema: {
type: 'object', type: 'object',
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } }, 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`, `No camera has id ${args.camera_id}; use find_cctv_cameras first`,
); );
const frame = await services.cctv.getFrame(camera, { signal }); 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( throw new ToolError(
'unavailable', 'unavailable',
`Camera ${camera.name || camera.id} has no still image right now`, `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', 'The camera image is too large to return',
); );
const name = text(camera.name) || camera.id; 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 { 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: { data: {
view: suggestView(services, { view: suggestView(services, {
point: { lat: camera.lat, lon: camera.lon }, point: { lat: camera.lat, lon: camera.lon },
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
id: camera.id, id: camera.id,
name, name,
city: text(camera.city), city: text(camera.city),
provider: text(camera.provider), provider: fallback ? 'Google Street View' : text(camera.provider),
credit: text(camera.credit), credit,
source_kind: frame.sourceKind ?? null,
current_view: current ? true : fallback ? false : null,
mime_type: frame.contentType, mime_type: frame.contentType,
bytes: frame.bytes.byteLength, bytes: frame.bytes.byteLength,
}, },
+184 -1
View File
@@ -30,7 +30,13 @@ const cameras = [
{ id: 'no-position', name: 'Unplaced' }, { id: 'no-position', name: 'Unplaced' },
]; ];
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); 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: [], frames: [],
async getCatalog() { async getCatalog() {
return { sources: cameras }; 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 () => { test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
const catalog = (frame) => const catalog = (frame) =>
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } }); composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });