Merge branch 'main' into fix/map-fallback-retry

This commit is contained in:
Sameh Khamis committed 2026-10-16 17:11:05 -07:00
commit 1c2910481e
19 files changed
+545 -24

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it. - **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback. - **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered. - **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim. - **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder request; other names use Google when configured, then Photon and Nominatim.
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs. - **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)). - **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
+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.
+3
View File
@@ -0,0 +1,3 @@
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
the existing coordinate framing and label. Unsupported URI fields are passed
to the remaining search providers unchanged (cj-vana, #1071).
@@ -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).
+19 -3
View File
@@ -1375,6 +1375,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
@@ -1397,7 +1405,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.
@@ -4714,12 +4725,17 @@ Coordinate input requires exactly two nonempty components; extra comma or semico
Location search/fly-to, annotations and Radio location lookup receive one Location search/fly-to, annotations and Radio location lookup receive one
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
two offline providers first — a strict decimal-degree coordinate reader and an two offline providers first — a strict decimal-degree/geo-URI coordinate reader and an
exact-match reader over the bundled city and landmark names — then Google when exact-match reader over the bundled city and landmark names — then Google when
configured and Photon/OpenStreetMap as fallback, including Google transport configured and Photon/OpenStreetMap as fallback, including Google transport
failures or declined requests. The offline pair answer with no request and no failures or declined requests. The offline pair answer with no request and no
key, and decline everything they are not certain of: a coordinate must be a key, and decline everything they are not certain of: a coordinate must be a
decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name decimal-degree pair (either order when N/S/E/W fix the axes), or a two-dimensional
WGS84 `geo:latitude,longitude` URI with an optional `;crs=wgs84`. URI scheme and
CRS names are case-insensitive; internal whitespace, altitude, uncertainty, other
parameters, query strings and fragments decline rather than silently dropping
information. For example, `geo:39.7392,-104.9903` resolves offline to the same exact
point, decimal label and framing box as `39.7392, -104.9903`. A bundled name
must match exactly, so `12junk, 34oops` and `austin, tx` both reach the must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and geocoders instead of being answered from the bundle. Degrees/minutes/seconds and
grid references are not parsed. An answer either provider gives is exact, so grid references are not parsed. An answer either provider gives is exact, so
+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": []
}, },
+58
View File
@@ -84,6 +84,63 @@ try {
() => window.__godsEyeView.styleManager._activePoiIndex === 1, () => window.__godsEyeView.styleManager._activePoiIndex === 1,
), ),
); );
const geocoderRequests = [];
const recordGeocoderRequest = (request) => {
const url = new URL(request.url());
if (
url.pathname === '/api/geocode' ||
url.pathname === '/api/google/geocode' ||
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
)
geocoderRequests.push(request.url());
};
page.on('request', recordGeocoderRequest);
await page.$eval('#location-search', (input) => {
input.value = 'geo:39.7392,-104.9903';
input.focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(
() => {
const state =
window.__godsEyeView.styleManager._locationLookup.getState();
return (
state.query === 'geo:39.7392,-104.9903' && state.status === 'found'
);
},
{ timeout: 30000 },
);
await page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 10000 },
);
check(
'native geo URI search shows the decimal coordinate readout',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
);
}),
);
check(
'geo URI navigation settles near the requested coordinate',
await page.evaluate(() => {
const position = window.__godsEyeView.viewer.camera.positionCartographic;
const degrees = 180 / Math.PI;
return (
Math.abs(position.latitude * degrees - 39.7392) < 0.05 &&
Math.abs(position.longitude * degrees + 104.9903) < 0.05
);
}),
);
check(
'geo URI search makes no geocoder request',
geocoderRequests.length === 0,
);
page.off('request', recordGeocoderRequest);
await page.evaluate(() => { await page.evaluate(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup; const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = []; window.__qaSearchRequests = [];
@@ -96,6 +153,7 @@ try {
window.__qaSearchRequests.push({ query, options, resolve }), window.__qaSearchRequests.push({ query, options, resolve }),
); );
document.getElementById('location-search').value = 'First'; document.getElementById('location-search').value = 'First';
document.getElementById('location-search').focus();
}); });
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 1); await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
+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);
}
});
+1 -1
View File
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
} }
/** /**
* Answer a decimal-degree query without asking anyone. * Answer a decimal-degree or geo-URI query without asking anyone.
* *
* It sits ahead of the network geocoders, so a coordinate costs no request and * It sits ahead of the network geocoders, so a coordinate costs no request and
* works with no key. Anything that is not exactly a coordinate is passed on * works with no key. Anything that is not exactly a coordinate is passed on
+13 -3
View File
@@ -5,7 +5,8 @@
* as 12, 34 flies the operator somewhere they did not ask for and looks like a * as 12, 34 flies the operator somewhere they did not ask for and looks like a
* successful search. So every rule here is exact — a component is a decimal * successful search. So every rule here is exact — a component is a decimal
* number, optionally signed, optionally marked with one hemisphere letter, and * number, optionally signed, optionally marked with one hemisphere letter, and
* nothing else. Degrees/minutes/seconds and grid references are not handled; * nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
* Degrees/minutes/seconds and grid references are not handled;
* they fall through to the geocoders, which is what happens today. * they fall through to the geocoders, which is what happens today.
*/ */
@@ -13,6 +14,10 @@
const COMPONENT = const COMPONENT =
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i; /^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
const GEO_URI =
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
/** Hemisphere letters, by the axis each one can name. */ /** Hemisphere letters, by the axis each one can name. */
const LATITUDE_LETTERS = new Set(['N', 'S']); const LATITUDE_LETTERS = new Set(['N', 'S']);
@@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) {
} }
/** /**
* Parse a decimal-degree query, or return null. * Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
* *
* Both orders are accepted when the hemisphere letters say which is which * Both orders are accepted when the hemisphere letters say which is which
* (`W 40, N 74` is longitude then latitude); with no letters the conventional * (`W 40, N 74` is longitude then latitude); with no letters the conventional
* latitude-then-longitude order applies. Two letters naming the same axis, a * latitude-then-longitude order applies. Two letters naming the same axis, a
* letter that fixes nothing, an out-of-range latitude or longitude, and any * letter that fixes nothing, an out-of-range latitude or longitude, and any
* trailing text all decline. * trailing text all decline.
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
* *
* @param {string} query * @param {string} query
* @returns {{ lat: number, lon: number, label: string } | null} * @returns {{ lat: number, lon: number, label: string } | null}
*/ */
export function parseCoordinateQuery(query) { export function parseCoordinateQuery(query) {
if (typeof query !== 'string') return null; if (typeof query !== 'string') return null;
const parts = splitComponents(query); const text = query.trim();
const parts = /^geo:/i.test(text)
? GEO_URI.exec(text)?.slice(1, 3)
: splitComponents(text);
if (!parts) return null; if (!parts) return null;
const first = readComponent(parts[0]); const first = readComponent(parts[0]);
+64
View File
@@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => {
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90); assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180); assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
}); });
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
for (const query of [
'geo:39.7392,-104.9903',
'geo:39.7392,-104.9903;crs=wgs84',
' GEO:39.7392,-104.9903;CRS=WGS84 ',
]) {
assert.deepEqual(parseCoordinateQuery(query), {
lat: 39.7392,
lon: -104.9903,
label: '39.7392° N, 104.9903° W',
});
}
for (const [query, decimal] of [
['geo:0,0', '0,0'],
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
['geo:90,180', '90,180'],
['geo:-90,-180;crs=wgs84', '-90,-180'],
['geo:09,007', '9,7'],
['geo:-0,-0', '-0,-0'],
]) {
assert.deepEqual(
parseCoordinateQuery(query),
parseCoordinateQuery(decimal),
);
}
});
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
for (const query of [
'geo:',
'geo:39.7392',
'geo:,104.9903',
'geo:39.7392,,104.9903',
'geo:39.7392,-104.9903,',
'geo:91,0',
'geo:-90.1,0',
'geo:0,181',
'geo:0,-180.1',
'geo:000,0',
'geo:0,0000',
'geo:+39.7392,-104.9903',
'geo:.5,-.25',
'geo:1e1,2',
'geo:NaN,0',
'geo:Infinity,0',
'geo:39.7392, -104.9903',
'geo:39.7392;-104.9903',
'geo:39.7392N,104.9903W',
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
'geo:39.7392,-104.9903;crs=wgs84;u=0',
'geo:39.7392,-104.9903;custom=value',
'geo:0,0?q=39.7392,-104.9903(Denver)',
'geo:39.7392,-104.9903#Denver',
'geo:39.7392,-104.9903 trailing text',
'geo://39.7392,-104.9903',
'https://example.com/geo:39.7392,-104.9903',
]) {
assert.equal(parseCoordinateQuery(query), null, query);
}
});
+50
View File
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
import { createCoordinateGeocoder } from './coordinateGeocoder.js'; import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js'; import { createPresetGeocoder } from './presetGeocoder.js';
import { createPlaceSearch } from './placeSearch.js'; import { createPlaceSearch } from './placeSearch.js';
import { createDefaultPlaceSearch } from './defaults.js';
const PRESETS = { const PRESETS = {
austin: { austin: {
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
); );
} }
}); });
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
const requests = [];
const search = createDefaultPlaceSearch({
resolveApiKey: () => 'test-key',
fetchImpl: async (url) => {
requests.push(url);
throw new Error('No network request should be needed');
},
});
const { place, fallbackUsed } = await search.geocode(
'geo:39.7392,-104.9903;crs=wgs84',
);
assert.deepEqual(requests, []);
assert.equal(fallbackUsed, false);
assert.equal(place.lat, 39.7392);
assert.equal(place.lng, -104.9903);
assert.equal(place.exact, true);
assert.deepEqual(place.types, ['coordinate']);
assert.ok(place.viewport.southwest.lat < place.lat);
assert.ok(place.viewport.northeast.lat > place.lat);
assert.ok(place.viewport.southwest.lng < place.lng);
assert.ok(place.viewport.northeast.lng > place.lng);
});
test('unsupported geo URIs reach the next search provider unchanged', async () => {
const queries = [
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:0,0?q=Denver',
];
const received = [];
const search = createPlaceSearch({
providers: [
createCoordinateGeocoder(),
{
async geocode(query) {
received.push(query);
return { place: null, answered: true };
},
},
],
});
for (const query of queries) {
assert.equal((await search.geocode(query)).place, null);
}
assert.deepEqual(received, queries);
});
+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) } });