From c5b1d41cbade33efd598286a104e49b9cd1d2952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:30:49 +0200 Subject: [PATCH 01/21] fix(search): geocode through the server, not the browser - Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only --- CHANGELOG.md | 9 ++ SECURITY.md | 6 +- docs/CURRENT-STATE.md | 19 ++- server/providers/local.js | 3 + server/providers/places.js | 7 +- server/providers/places/google-key.js | 33 +++- server/providers/places/google.js | 155 ++++++++++++++++++ src/annotations/annotationEngine.test.mjs | 2 +- src/annotations/annotationResolver.test.mjs | 20 +-- src/googleGeocodeProxy.test.mjs | 167 ++++++++++++++++++++ src/googleServerKey.test.mjs | 16 +- src/search/defaults.js | 23 +-- src/search/http.js | 15 +- src/search/placeSearch.test.mjs | 2 +- src/voice/gevActions.test.mjs | 6 +- 15 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 src/googleGeocodeProxy.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4be0a..526a89bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Move Google geocoding behind the local server. Forward and reverse lookups now + go through `/api/google/geocode` and `/api/google/reverse-geocode` with the + server key, joining Places and Street View. Google's Geocoding web service + refuses referrer-restricted keys, so the previous browser call left + `SECURITY.md`'s advice and working search mutually exclusive: the bundled + browser key had to be unrestricted for search to work at all. That key now + needs the Map Tiles API only. Keyless sessions and the Photon/Nominatim + fallback order are unchanged. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing diff --git a/SECURITY.md b/SECURITY.md index a21ea99a4..661e8764f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,18 +20,18 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | -| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: -1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. -**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. +**Places, Geocoding and Street View never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c381ac0c..e038bfe20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3445,7 +3445,24 @@ Providers normalize coordinates, canonical name, label, place types and optional bounds. Camera framing, nearby landmark recovery and footprint selection remain in their consumers, including the Capitol identity/containment safeguards. Radio keeps localized country names in labels rather than station filters. -Reverse geocoding and nearby/text-search endpoints retain their existing behavior. +Nearby/text-search endpoints retain their existing behavior. + +**Google geocoding is a server route, not a browser call** (#363). Forward and +reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`, +which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`) +and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle. +Google's Geocoding **web service** refuses referrer-restricted keys, so the +previous browser call forced the bundled browser key — visible in devtools by +design — to be left unrestricted; it now needs the Map Tiles API only. Both +routes answer in Google's own `status`/`results` shape rather than the Places +routes' `places: []` contract, because the browser adapters normalize that shape +directly. The browser key still gates the provider client-side, so a keyless +session spends no request to learn it has no Google: the route's own keyless +answer reads as an unanswered provider, which would stop the chain caching the +negative Photon or Nominatim then produces. Its query is capped at 200 +characters, its reverse coordinates are validated against WGS84 bounds before +the request is paid for, and a malformed viewport bias is dropped rather than +refused, since a bias only ranks results. Only valid answers and definitive misses enter bounded caches; malformed replies, HTTP refusals and outages remain retryable. Searches share a 12-second total diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..07e1a5bb8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -66,6 +66,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { googlePlacesContextProxy } from './places.js'; export { googleServerApiKey } from './places.js'; export { keylessGooglePlacesResponse } from './places.js'; +export { keylessGoogleGeocodeResponse } from './places.js'; +export { validateGeocodeAddress } from './places.js'; +export { geocodeBounds } from './places.js'; export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; export { readResponseTextCapped } from './common/http.js'; export { readResponseJsonCapped } from './common/http.js'; diff --git a/server/providers/places.js b/server/providers/places.js index 648be2aa8..6c36627e0 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,8 +1,13 @@ /** Place-search and route middleware for Node development servers. */ -export { googlePlacesContextProxy } from './places/google.js'; +export { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from './places/google.js'; export { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; export { diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js index 353bc9e24..a5c54b302 100644 --- a/server/providers/places/google-key.js +++ b/server/providers/places/google-key.js @@ -14,12 +14,33 @@ export function keylessGooglePlacesResponse(apiKey) { } /** - * Google API key for the SERVER-SIDE calls (Places nearby/text search, the - * CCTV Street View fallback). These never reach the browser, so this key can - * be restricted by server IP and scoped to Places API + Street View Static - * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + - * Geocoding for the browser (#33). Splitting them is opt-in: unset, this - * falls back to the shared browser key and nothing changes. + * Geocoding speaks Google's own response shape rather than the `places: []` + * contract its two siblings use, because the browser adapters read `status` + * and `results` directly. The keyless capability reads the same way: a status + * that is not `OK` and no results, which the adapters already treat as "this + * provider did not answer" and pass to the next geocoder in the chain. + */ +export function keylessGoogleGeocodeResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, + * geocoding, the CCTV Street View fallback). These never reach the browser, so + * this key can be restricted by server IP and scoped to Places API + Geocoding + * API + Street View Static API — while GOOGLE_MAPS_API_KEY stays + * referrer-restricted to Map Tiles for the browser (#33, #363). Splitting them + * is opt-in: unset, this falls back to the shared browser key, which then has + * to carry every API both sides use and cannot take a referrer restriction. */ export function googleServerApiKey() { return resolveGoogleServerKey(process.env); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 1ee83a892..8895bc19d 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,6 +1,7 @@ import { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -42,6 +43,37 @@ export function validatePlacesCoordinates(searchParams) { return { ok: true, latitude, longitude }; } +/** Longest accepted geocoder query. Real place names are far shorter. */ +const GEOCODE_MAX_QUERY = 200; + +/** Validate a free-text geocoder query before consuming request quota. */ +export function validateGeocodeAddress(searchParams) { + const address = String(searchParams.get('address') || '').trim(); + if (!address) return { ok: false, error: 'address is required' }; + if (address.length > GEOCODE_MAX_QUERY) + return { ok: false, error: 'address is too long' }; + return { ok: true, address }; +} + +/** + * Validate the optional viewport bias, which Google reads as + * `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than + * refused: the bias only ranks results, so a malformed one costs relevance, + * not an answer. + */ +export function geocodeBounds(searchParams) { + const raw = String(searchParams.get('bounds') || '').trim(); + if (!raw) return null; + const corners = raw.split('|'); + if (corners.length !== 2) return null; + const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); + if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const [swLat, swLon, neLat, neLon] = numbers; + if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; + if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; + return raw; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -274,6 +306,129 @@ export function googlePlacesContextProxy({ ); } }); + + /** + * Geocoding belongs on the server, not in the page. Google's Geocoding web + * service refuses referrer-restricted keys, so calling it from the browser + * forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be + * left unrestricted (#363). Here the request carries the server key, and + * the browser key no longer needs the Geocoding API at all. + * + * Unlike the two Places routes above, these answer in Google's own shape + * (`status` + `results`) rather than the `places: []` contract: the browser + * normalizers read that shape directly, and keeping it means the proxy adds + * a hop without adding a translation nobody asked for. + */ + function installGeocodeRoute(path, readQuery) { + middlewares.use(path, async (req, res) => { + const refuse = (statusCode, error) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }), + ); + }; + + if (req.method !== 'GET') { + refuse(405, 'Method not allowed'); + return; + } + + // A keyless geocode has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the + // Places routes above). + const apiKey = resolveApiKey(); + const keyless = keylessGoogleGeocodeResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const query = readQuery(requestUrl.searchParams); + if (!query.ok) { + refuse(400, query.error); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with + // the Places routes because both spend the same Google budget. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end( + JSON.stringify({ + status: 'OVER_QUERY_LIMIT', + results: [], + error: 'Rate limit exceeded', + }), + ); + return; + } + + try { + const upstream = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + for (const [name, value] of Object.entries(query.params)) + upstream.searchParams.set(name, value); + upstream.searchParams.set('key', apiKey); + const response = await fetchImpl(upstream.toString(), { + redirect: 'error', + }); + const data = await response.json().catch(() => null); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify( + data ?? { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }, + ), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: error?.message || 'Google Geocoding request failed', + }), + ); + } + }); + } + + installGeocodeRoute('/api/google/geocode', (searchParams) => { + const address = validateGeocodeAddress(searchParams); + if (!address.ok) return address; + const bounds = geocodeBounds(searchParams); + return { + ok: true, + params: { + address: address.address, + ...(bounds ? { bounds } : {}), + }, + }; + }); + + installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => { + const coordinates = validatePlacesCoordinates(searchParams); + if (!coordinates.ok) return coordinates; + const { latitude, longitude } = coordinates; + return { ok: true, params: { latlng: `${latitude},${longitude}` } }; + }); } return { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 0d26974ae..cc43b612a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -400,7 +400,7 @@ test('outline upgrade updates the rendered element in place without remove/add', }; let overpassCall = 0; globalThis.fetch = async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 452d6997c..2c51213c1 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/); + assert.match(String(url), /^\/api\/google\/geocode\?/); return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -273,7 +273,7 @@ for (const fixture of [ const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: fixture.lat, lon: fixture.lon, @@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { const query = new URL(String(url)).searchParams.get('address'); return { json: async () => geocodePayload({ lat: 39.7817, @@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [ { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, ]) { installGoogleMocks(t, async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ formatted_address: 'Washington, DC 20004, USA', types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], @@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; return { ok: true, json: async () => ({ @@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy high: { latitude: 30.28, longitude: -97.68 }, }); // Google is asked first and exactly once; one Photon call answers it. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); }); @@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s installGoogleMocks(t, async (url, init) => { const href = String(url); - if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; if (href.startsWith('https://photon.komoot.io/')) { return { @@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (!photonReachable) throw new Error('network down'); return photonUp; }); diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs new file mode 100644 index 000000000..2518d02cb --- /dev/null +++ b/src/googleGeocodeProxy.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from '../vite.config.js'; + +const UPSTREAM = 'https://geocode.test/json'; + +function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) { + const routes = new Map(); + googlePlacesContextProxy({ + resolveApiKey: () => apiKey, + fetchImpl, + endpoints: { geocode: UPSTREAM }, + }).configureServer({ + middlewares: { use: (name, handler) => routes.set(name, handler) }, + }); + return routes; +} + +function invokeRoute(handler, { method = 'GET', url = '/' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const res = { + statusCode: 200, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: body ? JSON.parse(String(body)) : null, + }); + }, + }; + Promise.resolve( + handler( + { method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, + res, + ), + ).catch(reject); + }); +} + +test('geocoder query and viewport bias are validated before Google is paid', () => { + assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), { + ok: false, + error: 'address is required', + }); + assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), { + ok: false, + error: 'address is required', + }); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })), + { ok: false, error: 'address is too long' }, + ); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })), + { ok: true, address: 'Austin' }, + ); + + // A bias only ranks results, so an unusable one is dropped, not refused. + const bias = '30.1,-97.9|30.4,-97.6'; + assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias); + for (const bounds of [ + '', + '30.1,-97.9', + '30.1,-97.9|30.4', + 'north,-97.9|30.4,-97.6', + '91,-97.9|30.4,-97.6', + '30.1,-181|30.4,-97.6', + ]) + assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); +}); + +test('the geocode routes keep the key server-side and answer in Google shape', async () => { + const upstream = []; + const routes = installRoutes({ + fetchImpl: async (url) => { + upstream.push(new URL(String(url))); + return { + ok: true, + json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), + }; + }, + }); + const forward = routes.get('/api/google/geocode'); + const reverse = routes.get('/api/google/reverse-geocode'); + assert.equal(typeof forward, 'function'); + assert.equal(typeof reverse, 'function'); + + const forwarded = await invokeRoute(forward, { + url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6', + }); + assert.equal(forwarded.statusCode, 200); + assert.deepEqual(forwarded.body, { + status: 'OK', + results: [{ place_id: 'fixture' }], + }); + assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin'); + assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' }); + assert.equal(reversed.statusCode, 200); + assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + // The key travels to Google, never back to the page that asked. + assert.ok(!JSON.stringify(forwarded.body).includes('server-secret')); + assert.ok(!JSON.stringify(reversed.body).includes('server-secret')); + + // A malformed bias is dropped rather than forwarded. + await invokeRoute(forward, { url: '/?address=Austin&bounds=north' }); + assert.equal(upstream.at(-1).searchParams.has('bounds'), false); +}); + +test('a bad or keyless geocode request never reaches Google', async () => { + const upstream = []; + const fetchImpl = async (url) => { + upstream.push(String(url)); + return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + }; + + const routes = installRoutes({ fetchImpl }); + for (const [path, url] of [ + ['/api/google/geocode', '/?address='], + ['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'], + ]) { + const response = await invokeRoute(routes.get(path), { url }); + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body.results, []); + assert.equal(response.body.status, 'REQUEST_DENIED'); + } + + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const rejected = await invokeRoute(routes.get(path), { + method: 'POST', + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(rejected.statusCode, 405); + } + + // Keyless is an empty capability, not an outage: a 200 the browser adapters + // read as "this provider did not answer", so the chain moves on. It resolves + // ahead of validation, like the Places routes: with nothing configured there + // is no request to validate and no quota to protect. + const keyless = installRoutes({ apiKey: ' ', fetchImpl }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) + for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) { + const response = await invokeRoute(keyless.get(path), { url }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.deepEqual(response.body, { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }); + } + + assert.deepEqual(upstream, []); +}); diff --git a/src/googleServerKey.test.mjs b/src/googleServerKey.test.mjs index 0851afcd5..2789219f5 100644 --- a/src/googleServerKey.test.mjs +++ b/src/googleServerKey.test.mjs @@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the } }); -test('both Places routes select the intended key and keep it out of responses', async (t) => { +test('every Google route selects the intended key and keeps it out of responses', async (t) => { const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN }; const calls = []; t.mock.method(globalThis, 'fetch', async (url, options) => { - calls.push({ url, key: options.headers['X-Goog-Api-Key'] }); + // Places sends the key as a header; Geocoding's web service takes it as a + // query parameter. Either way it must be the server key, and never the + // browser's copy of it. + calls.push({ + url, + key: + options?.headers?.['X-Goog-Api-Key'] ?? + new URL(String(url)).searchParams.get('key'), + }); return Response.json({ places: [] }); }); try { @@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses', for (const install of ['configureServer', 'configurePreviewServer']) { const routes = new Map(); googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } }); - assert.equal(routes.size, 2); + assert.equal(routes.size, 4); for (const handler of routes.values()) { const before = calls.length; let body; const res = { setHeader() {}, end(value) { body = value; } }; - await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); + await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); assert.equal(res.statusCode, 200); assert.ok(!body.includes('server-secret')); if (expected) { diff --git a/src/search/defaults.js b/src/search/defaults.js index 2b0428025..247512405 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -40,18 +40,23 @@ export function createDefaultPlaceSearch({ ...(selected ? [selected] : [ + // Google answers through the local proxy, which holds the server + // key: its Geocoding web service refuses referrer-restricted keys, + // so calling it from here would force the bundled browser key to be + // left unrestricted (#363). That key still gates the provider, + // because a keyless session must not spend a request to learn it + // has no Google: the proxy's "not configured" reads as an + // unanswered provider, which stops the chain caching the negative + // Photon or Nominatim then produces. createGoogleGeocoder({ request(query, { bias, signal }) { - const key = resolveApiKey?.(); - if (!key) return null; - const url = new URL( - endpoints.geocode || - 'https://maps.googleapis.com/maps/api/geocode/json', + if (!resolveApiKey?.()) return null; + const params = new URLSearchParams({ address: query }); + if (bias) params.set('bounds', bias); + return fetchImpl( + `${endpoints.geocode || '/api/google/geocode'}?${params}`, + { signal }, ); - url.searchParams.set('address', query); - url.searchParams.set('key', key); - if (bias) url.searchParams.set('bounds', bias); - return fetchImpl(url.toString(), { signal }); }, }), createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }), diff --git a/src/search/http.js b/src/search/http.js index 5b1642c67..5afa35b75 100644 --- a/src/search/http.js +++ b/src/search/http.js @@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({ endpoints = {}, } = {}) { const urls = { - reverse: 'https://maps.googleapis.com/maps/api/geocode/json', + // Same-origin like its siblings below: Google's Geocoding web service + // refuses referrer-restricted keys, so the key stays on the server (#363). + reverse: '/api/google/reverse-geocode', textSearch: '/api/google/text-search', nearby: '/api/google/nearby-places', route: '/api/route', @@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({ }, routeProfiles: ['foot', 'car', 'bike'], async reverseGeocode(latitude, longitude, options) { - const key = resolveApiKey?.(); - if (resolveApiKey && !key) return null; + // The browser key gates the provider without travelling with the + // request: the proxy supplies the key Google actually sees. + if (resolveApiKey && !resolveApiKey()) return null; return normalizeGoogleReverse( - await json( - urls.reverse, - { latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) }, - options, - ), + await json(urls.reverse, { lat: latitude, lon: longitude }, options), ); }, async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) { diff --git a/src/search/placeSearch.test.mjs b/src/search/placeSearch.test.mjs index e609f9da9..05e1aaea0 100644 --- a/src/search/placeSearch.test.mjs +++ b/src/search/placeSearch.test.mjs @@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) { const urls = []; const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => { - urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit(); + urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit(); } }); const result = await service.geocode('Hanoi'); assert.equal(result.place.name, 'Hà Nội'); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ba0ed9cf7..1d8d28c97 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3082,7 +3082,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl const requests = []; installKeyedFetch(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; } assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/); @@ -3107,7 +3107,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9); assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9); // Google is asked first and exactly once; Photon answers unbiased, in one call. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/); assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased'); @@ -3120,7 +3120,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou // "No Radio station matched" for exactly the places it just resolved, while a // keyed install placed a station. The label may carry it; the filter may not. const { calls, dataManager } = radioSelectionHarness(); - installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/') + installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode') ? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) } : { ok: true, From 771c9e5632ae6f0177adc09bc4ab4c34b4698765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:56:33 +0200 Subject: [PATCH 02/21] fix(search): bound and sanitize the geocode proxy - reject blank viewport-bias components before Number() reads them as 0 - give the upstream call a 10s deadline and a 256 KiB capped body read - return fixed public text on failure instead of the upstream message --- server/providers/places/google.js | 60 +++++++++++++++++++++++-------- src/googleGeocodeProxy.test.mjs | 47 +++++++++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 8895bc19d..a4aa48a78 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -4,6 +4,7 @@ import { keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../common/http.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -46,6 +47,16 @@ export function validatePlacesCoordinates(searchParams) { /** Longest accepted geocoder query. Real place names are far shorter. */ const GEOCODE_MAX_QUERY = 200; +/** + * Upstream deadline and body ceiling for the geocode routes. The deadline stays + * armed through body consumption, so a stalled upstream cannot hold a handler + * open, and the cap bounds a chunked or length-omitted reply. A geocode answer + * is a handful of results; 256 KiB is far above any honest one. + */ +const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000; + +const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024; + /** Validate a free-text geocoder query before consuming request quota. */ export function validateGeocodeAddress(searchParams) { const address = String(searchParams.get('address') || '').trim(); @@ -66,8 +77,12 @@ export function geocodeBounds(searchParams) { if (!raw) return null; const corners = raw.split('|'); if (corners.length !== 2) return null; - const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); - if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const parts = corners.flatMap((corner) => corner.split(',')); + // A blank component must be refused before Number() turns it into 0 and a + // half-written corner like `30.4,` reads as the equator. + if (parts.length !== 4 || parts.some((part) => !part.trim())) return null; + const numbers = parts.map(Number); + if (!numbers.every(Number.isFinite)) return null; const [swLat, swLon, neLat, neLon] = numbers; if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; @@ -379,31 +394,48 @@ export function googlePlacesContextProxy({ for (const [name, value] of Object.entries(query.params)) upstream.searchParams.set(name, value); upstream.searchParams.set('key', apiKey); + const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS); const response = await fetchImpl(upstream.toString(), { redirect: 'error', + signal: deadline, }); - const data = await response.json().catch(() => null); + // An unreadable, oversized or timed-out body is not an answer: it + // falls through to the same shaped failure as a refusal below. + const data = await readResponseJsonCapped( + response, + GEOCODE_MAX_RESPONSE_BYTES, + deadline, + ).catch(() => null); + + // A body we could not read is an upstream failure, not an answer with + // a status of its own, so it does not borrow the upstream's code. + if (!data) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + return; + } res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end( - JSON.stringify( - data ?? { - status: 'UNKNOWN_ERROR', - results: [], - error: 'Google Geocoding request failed', - }, - ), - ); - } catch (error) { + res.end(JSON.stringify(data)); + } catch { + // Fixed public text: a transport, URL or runtime message would + // describe the server to the browser (SECURITY.md, sanitized errors). res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end( JSON.stringify({ status: 'UNKNOWN_ERROR', results: [], - error: error?.message || 'Google Geocoding request failed', + error: 'Google Geocoding request failed', }), ); } diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs index 2518d02cb..98ffd876e 100644 --- a/src/googleGeocodeProxy.test.mjs +++ b/src/googleGeocodeProxy.test.mjs @@ -73,6 +73,10 @@ test('geocoder query and viewport bias are validated before Google is paid', () 'north,-97.9|30.4,-97.6', '91,-97.9|30.4,-97.6', '30.1,-181|30.4,-97.6', + // A blank component must not read as 0 — that is the equator, not a corner. + '30.1,-97.9|30.4,', + '30.1,|30.4,-97.6', + '30.1,-97.9| ,-97.6', ]) assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); }); @@ -82,10 +86,7 @@ test('the geocode routes keep the key server-side and answer in Google shape', a const routes = installRoutes({ fetchImpl: async (url) => { upstream.push(new URL(String(url))); - return { - ok: true, - json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), - }; + return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] }); }, }); const forward = routes.get('/api/google/geocode'); @@ -123,7 +124,7 @@ test('a bad or keyless geocode request never reaches Google', async () => { const upstream = []; const fetchImpl = async (url) => { upstream.push(String(url)); - return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + return Response.json({ status: 'OK', results: [] }); }; const routes = installRoutes({ fetchImpl }); @@ -165,3 +166,39 @@ test('a bad or keyless geocode request never reaches Google', async () => { assert.deepEqual(upstream, []); }); + +test('an upstream failure reaches the browser as fixed public text', async () => { + const routes = installRoutes({ + fetchImpl: async () => { + throw new Error('connect ECONNREFUSED 10.0.0.7:443'); + }, + }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const response = await invokeRoute(routes.get(path), { + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); + } +}); + +test('an oversized upstream body is dropped rather than buffered', async () => { + const routes = installRoutes({ + // Two hundred times the 256 KiB ceiling, declared honestly. + fetchImpl: async () => + Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }), + }); + const response = await invokeRoute(routes.get('/api/google/geocode'), { + url: '/?address=Austin', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); +}); From 691be39c1b466666b5a2f757b12c1d8c8bcb5226 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:38:06 -0400 Subject: [PATCH 03/21] fix(tools): stop cancelled calls before execution and discard late results --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++ src/tools/catalog-cancellation.test.mjs | 41 +++++++++++++++++++++++++ src/tools/catalog.js | 2 ++ 4 files changed, 50 insertions(+) create mode 100644 src/tools/catalog-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..33ebae897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..47ce15815 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): stop cancelled calls before execution and discard late results + +Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs new file mode 100644 index 000000000..a374bd71e --- /dev/null +++ b/src/tools/catalog-cancellation.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeCatalog, defineTool } from './catalog.js'; +test('cancelled catalog calls cannot execute or return late results', async () => { + let calls = 0, + release, + block = false; + const action = defineTool({ + name: 'fixture_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return block + ? new Promise((r) => (release = r)) + : { summary: 'fixture', data: {} }; + }, + }); + const catalog = composeCatalog({ tools: [action] }); + const c = new AbortController(); + c.abort(); + await assert.rejects( + catalog.call('fixture_action', {}, { signal: c.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + block = true; + const running = new AbortController(); + const pending = catalog.call( + 'fixture_action', + {}, + { signal: running.signal }, + ); + while (!release) await Promise.resolve(); + running.abort(); + release({ summary: 'late', data: {} }); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 4d5106b6e..6faf82ce5 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -178,6 +178,7 @@ export function composeCatalog({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { + signal?.throwIfAborted(); const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); @@ -194,6 +195,7 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } + signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 0796efe603663237b8a4f2f3a7ac50d59e727d8c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 04/21] docs: address review scope and feature placement for #1031 --- docs/CURRENT-STATE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 47ce15815..709613bad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,6 +97,11 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Catalog calls check their cancellation signal before tool execution and after +the tool resolves, including nested calls. Cancellation discards late catalog +results without promising rollback of completed actions or invalidating correct +static-source caches. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -5310,8 +5315,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): stop cancelled calls before execution and discard late results - -Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. From fffbe67b6685ad37e6c31eeebdce2a41b1eab975 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:23:37 -0400 Subject: [PATCH 05/21] fix(tools): reject cancellation after async interception --- src/tools/catalog-cancellation.test.mjs | 37 +++++++++++++++++++++++++ src/tools/catalog.js | 12 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a374bd71e..a4a2b7c9d 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -39,3 +39,40 @@ test('cancelled catalog calls cannot execute or return late results', async () = await assert.rejects(pending, { name: 'AbortError' }); assert.equal(calls, 1); }); + +test('cancellation during an awaited interceptor cannot publish its late result', async () => { + const controller = new AbortController(); + const reason = new Error('interceptor caller canceled'); + let calls = 0; + const action = defineTool({ + name: 'fixture_intercepted_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'completed action', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [action], + interceptors: [ + async (call, next) => { + const result = await next(call); + await Promise.resolve(); + controller.abort(reason); + return result; + }, + ], + }); + await assert.rejects( + catalog.call( + 'fixture_intercepted_action', + {}, + { signal: controller.signal }, + ), + (error) => error === reason, + ); + assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 6faf82ce5..ea0e37460 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,13 +169,21 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = (name, args, signal, parent) => { + const invoke = async (name, args, signal, parent) => { + signal?.throwIfAborted(); const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + const result = await chain({ + tool, + args, + signal, + ...(parent ? { parent } : {}), + }); + signal?.throwIfAborted(); + return result; }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); From aacb409fbf74a8add2c6b57ab9a2c4326f8a7376 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:32:52 -0400 Subject: [PATCH 06/21] fix(tools): limit cancellation guard to pre-execution --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 7 +- src/tools/catalog-cancellation.test.mjs | 117 +++++++++--------------- src/tools/catalog.js | 13 +-- 4 files changed, 52 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ebae897..21b296fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. +- Cancelled catalog queries refuse execution when their signal is already aborted. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 709613bad..648fa0584 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,10 +97,9 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). -Catalog calls check their cancellation signal before tool execution and after -the tool resolves, including nested calls. Cancellation discards late catalog -results without promising rollback of completed actions or invalidating correct -static-source caches. +Catalog calls check their cancellation signal immediately before executing a +tool. Already-cancelled queries do not start; cancellation after execution starts +continues to follow the existing tool and caller behavior. ## Cyber HUD — September 30, 2026 diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a4a2b7c9d..0f666b262 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -1,78 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { composeCatalog, defineTool } from './catalog.js'; -test('cancelled catalog calls cannot execute or return late results', async () => { - let calls = 0, - release, - block = false; - const action = defineTool({ - name: 'fixture_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return block - ? new Promise((r) => (release = r)) - : { summary: 'fixture', data: {} }; - }, - }); - const catalog = composeCatalog({ tools: [action] }); - const c = new AbortController(); - c.abort(); - await assert.rejects( - catalog.call('fixture_action', {}, { signal: c.signal }), - { name: 'AbortError' }, - ); - assert.equal(calls, 0); - block = true; - const running = new AbortController(); - const pending = catalog.call( - 'fixture_action', - {}, - { signal: running.signal }, - ); - while (!release) await Promise.resolve(); - running.abort(); - release({ summary: 'late', data: {} }); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(calls, 1); -}); -test('cancellation during an awaited interceptor cannot publish its late result', async () => { - const controller = new AbortController(); - const reason = new Error('interceptor caller canceled'); - let calls = 0; - const action = defineTool({ - name: 'fixture_intercepted_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return { summary: 'completed action', data: {} }; - }, - }); - const catalog = composeCatalog({ - tools: [action], - interceptors: [ - async (call, next) => { - const result = await next(call); - await Promise.resolve(); - controller.abort(reason); - return result; +for (const intercept of [false, true]) { + test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => { + let calls = 0; + const controller = new AbortController(); + const query = defineTool({ + name: 'fixture_query', + kind: 'query', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'fixture', data: {} }; }, - ], + }); + const catalog = composeCatalog({ + tools: [query], + interceptors: intercept + ? [ + async (call, next) => { + await Promise.resolve(); + controller.abort(); + return next(call); + }, + ] + : [], + }); + if (!intercept) controller.abort(); + await assert.rejects( + catalog.call('fixture_query', {}, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + const fresh = composeCatalog({ tools: [query] }); + assert.equal( + ( + await fresh.call( + 'fixture_query', + {}, + { signal: new AbortController().signal }, + ) + ).summary, + 'fixture', + ); + assert.equal(calls, 1); }); - await assert.rejects( - catalog.call( - 'fixture_intercepted_action', - {}, - { signal: controller.signal }, - ), - (error) => error === reason, - ); - assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); -}); +} diff --git a/src/tools/catalog.js b/src/tools/catalog.js index ea0e37460..d617a9f28 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,21 +169,13 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = async (name, args, signal, parent) => { - signal?.throwIfAborted(); + const invoke = (name, args, signal, parent) => { const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - const result = await chain({ - tool, - args, - signal, - ...(parent ? { parent } : {}), - }); - signal?.throwIfAborted(); - return result; + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); @@ -203,7 +195,6 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } - signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 09018ac753e7edabc94eed09094267d591ee6fa4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:15 -0400 Subject: [PATCH 07/21] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b296fdf..0a761a798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. - - Cancelled catalog queries refuse execution when their signal is already aborted. - Treat explicitly current, complete empty vessel coverage as a successful update, From 29583c55ed045af3675dd9de2b10de7c64a4ab4c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:29 -0400 Subject: [PATCH 08/21] fix(firms): reject impossible acquisition days in CSV parsing Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/data/firms-calendar-validity.test.mjs | 23 +++++++++++++++++++++++ src/data/firmsCsv.js | 8 +++++++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/data/firms-calendar-validity.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..ca4974a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Reject impossible FIRMS acquisition calendar days in the CSV parser before trailing-24-hour filtering. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..6a4f3061b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. + The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential diff --git a/src/data/firms-calendar-validity.test.mjs b/src/data/firms-calendar-validity.test.mjs new file mode 100644 index 000000000..1ccaf7435 --- /dev/null +++ b/src/data/firms-calendar-validity.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { acquisitionMsUtc } from './firmsCsv.js'; +test('FIRMS acquisition dates cannot roll into a different calendar day', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16junk', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ['2026-07-16', null], + ['2026-07-16', ''], + ]) { + assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`); + } + for (const [date, time, expected] of [ + ['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)], + ['2026-07-16', '0', Date.UTC(2026, 6, 16)], + ]) { + assert.equal(acquisitionMsUtc(date, time), expected); + } +}); diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..53d132d99 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -153,7 +153,13 @@ export function acquisitionMsUtc(acqDate, acqTime) { minutes > 59 ) return NaN; - return Date.UTC(year, month - 1, day, hours, minutes); + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + return parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : NaN; } /** From 3962ac52f7195bf50314682742a6e64a33c1bde0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:02 -0400 Subject: [PATCH 09/21] fix(imagery): require explicit Data-Present proof before decoding thumbnails Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/layers/recentImagery/source.js | 6 +++- .../thumbnail-presence-proof.test.mjs | 30 +++++++++++++++++++ src/layers/recentImagery/thumbnails.js | 4 +-- 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/layers/recentImagery/thumbnail-presence-proof.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..6f5ee5c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery thumbnails require explicit presence evidence before reporting available pixels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..93e5214a5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,8 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Thumbnail decoding requires explicit `Data-Present: true` evidence; false remains empty, while absent or malformed evidence reports an error. Injected `getThumbnail` sources must return an OK Response with readable `Data-Present: true` to admit image bytes, or `false` for confirmed empty coverage. Cross-origin sources must expose that header to browser JavaScript. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. diff --git a/src/layers/recentImagery/source.js b/src/layers/recentImagery/source.js index 258e8e2e1..9e6749cb7 100644 --- a/src/layers/recentImagery/source.js +++ b/src/layers/recentImagery/source.js @@ -1,7 +1,11 @@ import { searchHls } from './catalog.js'; import { gibsTemplate, wvsSnapshotUrl } from './model.js'; -/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */ +/** + * NASA-compatible imagery acquisition; transport and URL builders are replaceable. + * Thumbnail responses must expose Data-Present: true for image admission, or + * false for confirmed empty coverage, including injected getThumbnail sources. + */ export function createRecentImagerySource({ fetchImpl = (...args) => globalThis.fetch(...args), search = searchHls, diff --git a/src/layers/recentImagery/thumbnail-presence-proof.test.mjs b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs new file mode 100644 index 000000000..4605079b1 --- /dev/null +++ b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createThumbnailLoader } from './thumbnails.js'; +test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => { + for (const header of [null, 'unknown', '', 'true', 'false']) { + let decodes = 0; + const loader = createThumbnailLoader({ + requestThumbnail: async () => ({ + ok: true, + headers: { get: (key) => (key === 'Data-Present' ? header : null) }, + blob: async () => { + decodes++; + return new Blob(['fixture']); + }, + }), + createObjectUrl: () => 'blob:fixture', + }); + loader.request( + { key: 'fixture', product: 'S30', day: '2026-09-18' }, + { west: 0, east: 1, south: 0, north: 1 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + loader.get('fixture').status, + header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error', + ); + assert.equal(decodes, header === 'true' ? 1 : 0); + loader.destroy(); + } +}); diff --git a/src/layers/recentImagery/thumbnails.js b/src/layers/recentImagery/thumbnails.js index 23fe4dd49..4aaec294b 100644 --- a/src/layers/recentImagery/thumbnails.js +++ b/src/layers/recentImagery/thumbnails.js @@ -100,12 +100,12 @@ export function createThumbnailLoader({ cancelled = true; } else if (response?.ok) { const present = String( - response.headers?.get?.('Data-Present') ?? 'true', + response.headers?.get?.('Data-Present') ?? '', ).toLowerCase(); acquisitionTime = response.headers?.get?.('Acquisition-Time') || null; if (present === 'false') { status = 'empty'; - } else { + } else if (present === 'true') { const blob = await response.blob(); if (controller.signal.aborted) cancelled = true; else { From af128bd4cd23c36bcdbd3dc30de6048d416ed645 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:51:14 -0400 Subject: [PATCH 10/21] fix(regional): keep impossible compact article dates unknown --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++ src/data/regional-calendar-evidence.test.mjs | 43 ++++++++++++++++++++ src/data/regionalModel.js | 17 ++++++-- 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/data/regional-calendar-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..6212e745c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..03d98aa1c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(regional): keep impossible compact article dates unknown + +Round-trip compact timestamps through a valid Date and retain the article with an unknown timestamp when its source date is impossible. Valid compact and ordinary ISO output formats are preserved. diff --git a/src/data/regional-calendar-evidence.test.mjs b/src/data/regional-calendar-evidence.test.mjs new file mode 100644 index 000000000..a442a9feb --- /dev/null +++ b/src/data/regional-calendar-evidence.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalArticles } from './regionalModel.js'; +test('compact source dates must denote an actual calendar instant', () => { + for (const date of [ + '20260230T120000Z', + '20260431T120000Z', + '20261301T120000Z', + '20260716T240000Z', + '20260716T126000Z', + '20260716T120060Z', + ]) { + const [article] = normalizeRegionalArticles({ + articles: [ + { + title: 'Fixture', + url: 'https://fixture.example/news', + seendate: date, + }, + ], + }); + assert.equal(article.publishedAt, null, date); + assert.equal(article.title, 'Fixture'); + } + for (const [input, expected] of [ + ['20240229T004500Z', '2024-02-29T00:45:00Z'], + ['20260716T000000Z', '2026-07-16T00:00:00Z'], + ['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'], + ]) { + assert.equal( + normalizeRegionalArticles({ + articles: [ + { + title: 'Fixture', + url: 'https://fixture.example/news', + seendate: input, + }, + ], + })[0].publishedAt, + expected, + ); + } +}); diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 3a6d79f14..fca473994 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { seen.add(signature); const rawDate = cleanText(row?.seendate, 32); const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); - const publishedAt = compactDate + const candidateDate = compactDate ? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z` - : Number.isNaN(Date.parse(rawDate)) - ? null - : new Date(rawDate).toISOString(); + : rawDate; + const parsedMs = Date.parse(candidateDate); + const validDate = + Number.isFinite(parsedMs) && + (!compactDate || + new Date(parsedMs).toISOString().slice(0, 19) === + candidateDate.slice(0, 19)); + const publishedAt = validDate + ? compactDate + ? candidateDate + : new Date(parsedMs).toISOString() + : null; articles.push({ title, url, From 5cb79cd69df472db04059324e5b23673bb12b571 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:32 -0400 Subject: [PATCH 11/21] docs: address maintainer review for PR #1051 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 03d98aa1c..a8dec3873 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1140,6 +1140,8 @@ stalled downloads are cancelled. Development and preview use the same handler. ## Remaining local service modules +GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. + Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(regional): keep impossible compact article dates unknown - -Round-trip compact timestamps through a valid Date and retain the article with an unknown timestamp when its source date is impossible. Valid compact and ordinary ISO output formats are preserved. From 2c7d27ec6d31a4a6bcd3134c86a70f613c33623b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:15:51 -0400 Subject: [PATCH 12/21] docs: place publication-time behavior beside regional briefing --- docs/CURRENT-STATE.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a8dec3873..066cf5e37 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1140,12 +1140,11 @@ stalled downloads are cancelled. Development and preview use the same handler. ## Remaining local service modules -GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. - Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, -while weather effects uses only the weather source. Existing process-scoped +while weather effects uses only the weather source. +GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. Existing process-scoped caches, rate limits, stale fallbacks and route ordering are preserved. Local voice has separate HUD-summary, debug-log and Realtime-token handlers, From fa374025892e701ee6d9572379c4260c0a6e25a7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:17:02 -0400 Subject: [PATCH 13/21] docs: wrap regional publication-time prose --- docs/CURRENT-STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 066cf5e37..76d65d82d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1144,7 +1144,8 @@ Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, while weather effects uses only the weather source. -GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. Existing process-scoped +GDELT compact dates that do not name a real calendar instant keep the article +with an unknown publication time. Existing process-scoped caches, rate limits, stale fallbacks and route ordering are preserved. Local voice has separate HUD-summary, debug-log and Realtime-token handlers, From a37a94fba1a214d0c901e032d83d72622bfda8c5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:38 -0400 Subject: [PATCH 14/21] fix(services): retry malformed capability probes after the existing backoff Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 3 +- src/annotations/annotationEngine.test.mjs | 4 +- src/services/boundary-probe-recovery.test.mjs | 38 +++++++++++++++++++ src/services/requests.js | 15 +++++--- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 src/services/boundary-probe-recovery.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..24ed601cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Boundary capability checks recover after unsuccessful or malformed status responses, using the existing retry backoff. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..d8409281f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4066,7 +4066,8 @@ when its own signal aborts. Configured endpoints have per-instance cooldowns for 406/429, honoring Retry-After; absent delays back off from 30 seconds to five minutes. Empty `elements` is a valid answer. Existing byte, query, concurrency and cache caps -remain. Consumer capability misses never schedule automatic retries. +remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. +Consumer capability misses never schedule automatic retries. Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..3c4b9165b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; diff --git a/src/services/boundary-probe-recovery.test.mjs b/src/services/boundary-probe-recovery.test.mjs new file mode 100644 index 000000000..cc0c47c08 --- /dev/null +++ b/src/services/boundary-probe-recovery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; +test('unknown capability replies recover after backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100_000 }); + for (const first of [ + () => Response.json({ wrong: true }), + () => new Response('bad', { status: 503 }), + ]) { + let probes = 0; + let queries = 0; + const services = createApplicationRequestServices({ + boundaryProbe: { timeoutMs: 1000, retryMs: 1000 }, + fetchImpl: async (url) => { + if (url.endsWith('/status')) + return ++probes === 1 + ? first() + : Response.json({ configured: false }); + queries++; + return Response.json({ elements: [] }); + }, + }); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff'); + t.mock.timers.tick(1000); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal(probes, 2); + assert.equal(queries, 2); + } +}); diff --git a/src/services/requests.js b/src/services/requests.js index bb0b4cefd..3e3a819eb 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -66,9 +66,9 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - // Learn once per page whether the server has an Overpass instance, so an + // Remember valid capability decisions for the page, so an // unconfigured server is never asked (and never answers with an error). - // Discovery is bounded; a failed or timed-out probe is forgotten and not + // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. let boundaryCapability = null; @@ -79,10 +79,13 @@ export function createApplicationRequestServices({ boundaryCapability = request(`${urls.boundaries}/status`, { signal: AbortSignal.timeout(boundaryProbe.timeoutMs), }).then( - (response) => - response.ok && typeof response.data?.configured === 'boolean' - ? response.data.configured - : null, // an older server: fall back to asking per query + (response) => { + if (response.ok && typeof response.data?.configured === 'boolean') + return response.data.configured; + boundaryCapability = null; + boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs; + return null; // capability unknown: let this query ask the server + }, (error) => { boundaryCapability = null; if (lifetime?.aborted) throw error; From 4fcca5b21564f28c58d94fda02d4987d7d7f8fc5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:40 -0400 Subject: [PATCH 15/21] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6212e745c..098fc54a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. - Treat explicitly current, complete empty vessel coverage as a successful update, From 360aa74f50c72cc485734978977bf0884de06282 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:52 -0400 Subject: [PATCH 16/21] fix(imagery): paginate raw CMR pages within the record budget Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/layers/recentImagery/catalog.js | 18 ++++-- .../cmr-raw-page-budget.test.mjs | 56 +++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/cmr-raw-page-budget.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..9b4d82063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery continues past malformed records in full catalog pages and respects the requested record limit. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..b65f90ca0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,8 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +CMR pagination counts raw rows when checking page completion and the record budget; catalog truncation means upstream hits exceed rows examined, so discarded malformed rows do not imply unseen imagery. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. diff --git a/src/layers/recentImagery/catalog.js b/src/layers/recentImagery/catalog.js index 96b314690..fe9a7a021 100644 --- a/src/layers/recentImagery/catalog.js +++ b/src/layers/recentImagery/catalog.js @@ -40,6 +40,7 @@ export async function fetchCmrPages({ }) { const granules = []; let hits = 0; + let readCount = 0; let cursor = null; for (;;) { const init = { signal }; @@ -47,19 +48,24 @@ export async function fetchCmrPages({ const response = await fetchImpl(url, init); if (!response?.ok) throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`); - const page = parseCmrUmm(await response.json(), product); - granules.push(...page.granules); + const json = await response.json(); + const rawCount = Array.isArray(json?.items) ? json.items.length : 0; + const page = parseCmrUmm(json, product); + readCount += rawCount; + granules.push( + ...page.granules.slice(0, Math.max(0, maxRecords - granules.length)), + ); hits = Math.max(hits, page.hits); cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null; if ( !cursor || - page.granules.length < CMR_PAGE_SIZE || - granules.length >= maxRecords || - granules.length >= hits + rawCount < CMR_PAGE_SIZE || + readCount >= maxRecords || + readCount >= hits ) break; } - return { granules, truncated: hits > granules.length }; + return { granules, truncated: hits > readCount }; } function finiteOrNull(value) { diff --git a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs new file mode 100644 index 000000000..4925e7bec --- /dev/null +++ b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchCmrPages } from './catalog.js'; +const item = (id) => ({ + meta: { 'concept-id': id }, + umm: { + TemporalExtent: { + RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' }, + }, + }, +}); +test('discarded rows do not turn a full CMR page into a final page', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + fetchImpl: async () => { + calls++; + const items = + calls === 1 + ? Array.from({ length: 200 }, (_, i) => + i === 0 ? {} : item(String(i)), + ) + : [item('last')]; + return Response.json( + { hits: 201, items }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 200); + assert.equal(result.granules.at(-1).id, 'last'); + assert.equal(result.truncated, false); +}); +test('a non-page-multiple limit never returns more admitted records than its budget', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + return Response.json( + { + hits: 1000, + items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)), + }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, true); +}); From 0555d99ea467454ec567767a7fee5c56e02c5459 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:14:54 -0400 Subject: [PATCH 17/21] fix(cctv): leave conflicting direction fields unresolved Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 5 +++ src/data/ambiguous-camera-direction.test.mjs | 30 +++++++++++++ src/data/directionText.js | 44 +++++++++++++------- 4 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 src/data/ambiguous-camera-direction.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..0ae01540e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Conflicting CCTV direction tokens remain unknown within the highest matching tier, preserving travel-token precedence over street quadrants and bare words. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..29bdb33c0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1172,6 +1172,11 @@ a browser key. Preview is for local build verification, not a production server. ## CCTV and radio provider modules +CCTV direction text resolves travel tokens first, then intercardinals, then +bare cardinals when enabled. Only conflicting headings within the highest +matching tier remain unknown; street quadrants and lower-tier words do not +cancel an explicit travel heading. + CCTV catalog acquisition, source normalization and frame/media delivery now live in separate modules. Each CCTV factory owns its catalog and health state; its `sourceRoot` option resolves relative source files against the application root. diff --git a/src/data/ambiguous-camera-direction.test.mjs b/src/data/ambiguous-camera-direction.test.mjs new file mode 100644 index 000000000..b5996d7a7 --- /dev/null +++ b/src/data/ambiguous-camera-direction.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { directionToHeading } from './directionText.js'; +test('conflicting facing directions cannot manufacture one heading', () => { + for (const bare of [false, true]) { + for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW']) + assert.ok(Number.isNaN(directionToHeading(value, bare)), value); + assert.equal(directionToHeading('NB northbound', bare), 0); + assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180); + } + assert.ok(Number.isNaN(directionToHeading('North / East', true))); + assert.equal(directionToHeading('West / SR-24', true), 270); +}); + +test('travel tokens retain precedence over quadrant streets and bare words', () => { + for (const [text, heading] of [ + ['I-5 NB at NE 45th St', 0], + ['SR 520 EB at NE 148th', 90], + ['US 13 SB @ SW Front St', 180], + ]) { + for (const bare of [false, true]) + assert.equal(directionToHeading(text, bare), heading); + } + assert.equal( + directionToHeading('Hwy 401 east of Keele St - Westbound', true), + 270, + ); + assert.equal(directionToHeading('NE near West St', true), 45); + assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true))); +}); diff --git a/src/data/directionText.js b/src/data/directionText.js index 78f1be0df..eb78065d2 100644 --- a/src/data/directionText.js +++ b/src/data/directionText.js @@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) { .trim() .toUpperCase(); if (!text) return NaN; - // Explicit travel/intercardinal forms — safe on free-form text (a street - // name almost never contains "NORTHBOUND" or a lone "NB" token). - if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0; - if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180; - if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90; - if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270; - if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45; - if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315; - if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135; - if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225; - // Bare cardinals — dedicated direction fields only. - if (allowBare) { - if (/\bNORTH\b/.test(text)) return 0; - if (/\bSOUTH\b/.test(text)) return 180; - if (/\bEAST\b/.test(text)) return 90; - if (/\bWEST\b/.test(text)) return 270; + const tiers = [ + [ + [/\bNORTHBOUND\b|\bNB\b/, 0], + [/\bSOUTHBOUND\b|\bSB\b/, 180], + [/\bEASTBOUND\b|\bEB\b/, 90], + [/\bWESTBOUND\b|\bWB\b/, 270], + ], + [ + [/\bNORTHEAST\b|\bNE\b/, 45], + [/\bNORTHWEST\b|\bNW\b/, 315], + [/\bSOUTHEAST\b|\bSE\b/, 135], + [/\bSOUTHWEST\b|\bSW\b/, 225], + ], + ]; + if (allowBare) + tiers.push([ + [/\bNORTH\b/, 0], + [/\bSOUTH\b/, 180], + [/\bEAST\b/, 90], + [/\bWEST\b/, 270], + ]); + for (const forms of tiers) { + const headings = new Set(); + for (const [pattern, heading] of forms) { + if (pattern.test(text)) headings.add(heading); + } + if (headings.size) + return headings.size === 1 ? headings.values().next().value : NaN; } return NaN; } From 19fcc00b3ee3697cec2c814262791bbce0933220 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:09 -0400 Subject: [PATCH 18/21] fix(terrain): reject out-of-range points at the proxy boundary Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 4 ++++ src/data/terrainHeightsProxy.js | 8 +++++++- src/data/terrainHeightsProxy.test.mjs | 11 +++++++++++ src/proxyErrorResponses.test.mjs | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..2d15df2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- The terrain proxy refuses out-of-range WGS84 coordinates before upstream resolution for every client. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..73e521e01 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,10 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +The terrain proxy parser rejects finite longitudes outside [-180, 180] and +latitudes outside [-90, 90] for every client before upstream resolution. One +out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..9134f9ec6 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) { if (parts.length !== 2) return null; const lon = Number(parts[0]); const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lon) > 180 || + Math.abs(lat) > 90 + ) + return null; points.push([lon, lat]); } return points; diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index f6b53de9a..50e45b732 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + parseTerrainPoints, fetchTerrainChunkWithRetry, resolveTerrainHeightRequest, terrainPointKey, @@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as assert.equal(response.cacheChanged, false); } }); + +test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => { + for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) + assert.equal(parseTerrainPoints(value), null, value); + assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [ + [-180, -90], + [180, 90], + [0, 0], + ]); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..c6a7269ab 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] @@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => { assert.equal(absent.status, 200); assert.deepEqual(JSON.parse(absent.body), { found: false }); }); + +for (const preview of [false, true]) { + test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => { + let calls = 0; + const app = fixture('terrainHeightsProxy', { parseTerrainPoints, + resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; }, + }, preview); + for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) { + assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400); + } + assert.equal(calls, 0); + assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200); + assert.equal(calls, 1); + }); +} From 12ea0219d1cf73be07b07fd9470f07bbb138758c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:28 -0400 Subject: [PATCH 19/21] chore(ui): harden surface Tab cycling Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 3 ++ src/ui/surface-tab-order.test.mjs | 57 +++++++++++++++++++++++++++++++ src/ui/surfaceKeyboard.js | 6 +++- 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/ui/surface-tab-order.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..cbd99b26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Surface Tab cycling also skips negative-tabindex, fieldset-disabled and inert controls. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..3d9c59110 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1060,6 +1060,9 @@ collapse can still be undone. ## Surface keyboard lifecycle +Surface Tab cycling excludes controls with a negative tabindex, native disabled +state or inert ancestry; ordinary visible enabled controls retain their order. + `ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return focus shared by the first-run launcher and Provider Settings. Each caller activates it while open and deactivates it on dismissal; destruction releases diff --git a/src/ui/surface-tab-order.test.mjs b/src/ui/surface-tab-order.test.mjs new file mode 100644 index 000000000..c3f335407 --- /dev/null +++ b/src/ui/surface-tab-order.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSurfaceKeyboard } from './surfaceKeyboard.js'; +test('surface cycling never targets deliberately excluded controls', () => { + let handler; + const documentRef = { + activeElement: {}, + addEventListener: (_, fn) => (handler = fn), + removeEventListener() {}, + }; + const node = ({ + attributes = {}, + fieldsetDisabled = false, + inert = false, + } = {}) => ({ + hasAttribute: (name) => Object.hasOwn(attributes, name), + getClientRects: () => [{}], + getAttribute: (name) => attributes[name] ?? null, + matches: (selector) => selector === ':disabled' && fieldsetDisabled, + closest: (selector) => + selector === '[inert]' && inert ? { inert: true } : null, + focus() { + documentRef.activeElement = this; + }, + }); + const ignored = [ + node({ attributes: { tabindex: '-1' } }), + node({ fieldsetDisabled: true }), + node({ inert: true }), + ]; + const first = node({}), + last = node({}); + const controls = [...ignored, first, last]; + const root = { + ownerDocument: documentRef, + querySelectorAll: () => controls, + contains: (n) => controls.includes(n), + }; + const surface = createSurfaceKeyboard({ + root, + isActive: () => true, + onEscape() {}, + }); + surface.activate(); + const press = (shiftKey) => + handler({ + key: 'Tab', + shiftKey, + preventDefault() {}, + stopPropagation() {}, + }); + press(false); + assert.equal(documentRef.activeElement, first); + press(true); + assert.equal(documentRef.activeElement, last); + surface.destroy(); +}); diff --git a/src/ui/surfaceKeyboard.js b/src/ui/surfaceKeyboard.js index 38417975a..141e22fa6 100644 --- a/src/ui/surfaceKeyboard.js +++ b/src/ui/surfaceKeyboard.js @@ -39,7 +39,11 @@ export function createSurfaceKeyboard({ ), ].filter( (node) => - !node.hasAttribute('disabled') && node.getClientRects().length > 0, + !node.hasAttribute('disabled') && + !node.matches?.(':disabled') && + !(Number(node.getAttribute?.('tabindex')) < 0) && + !node.closest?.('[inert]') && + node.getClientRects().length > 0, ); if (!order.length) return; const first = order[0]; From 26694380fb1136c8b3e49fbc41396b2585727ab5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 14:36:06 +0100 Subject: [PATCH 20/21] test(track): stub the geocode proxy routes, not the browser call Reverse geocoding now goes to /api/google/reverse-geocode on the app server, so the harness stub for maps.googleapis.com never matched and a keyed run would reach Google live. Answer both proxy routes with ZERO_RESULTS instead. Co-Authored-By: Claude Opus 5.5 --- scripts/track-regression.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 84aa2494e..756daccfc 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -443,7 +443,10 @@ async function main() { // for nearby names. Answer both with empty results: the entity-context // assertions are about the SELECTED contact, and a live lookup would // make the run non-hermetic (and bill the owner's Google quota). - if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) { + if ( + isAppRequest && + (url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode') + ) { return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] })); } if (isAppRequest && url.pathname === '/api/google/nearby-places') { From 64988f3037e33e46c336f6a914e83411821729fb Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 15:43:38 +0100 Subject: [PATCH 21/21] fix(imagery): mark CMR results truncated when the record cap drops valid granules Co-Authored-By: Claude Opus 5.5 --- src/layers/recentImagery/catalog.js | 13 ++++-- .../cmr-raw-page-budget.test.mjs | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/layers/recentImagery/catalog.js b/src/layers/recentImagery/catalog.js index fe9a7a021..14964b1d2 100644 --- a/src/layers/recentImagery/catalog.js +++ b/src/layers/recentImagery/catalog.js @@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After'; /** * Read one product's granules across CMR pages, sending back each response's * `CMR-Search-After` cursor until a short page, a missing cursor or the - * record cap ends the walk. `truncated` is measured against CMR's `hits`. + * record cap ends the walk. `truncated` means CMR's `hits` exceed the rows + * read, or the cap dropped valid granules from the last page read. * @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request * @returns {Promise<{ granules: Array, truncated: boolean }>} */ @@ -41,6 +42,7 @@ export async function fetchCmrPages({ const granules = []; let hits = 0; let readCount = 0; + let capped = 0; let cursor = null; for (;;) { const init = { signal }; @@ -52,9 +54,12 @@ export async function fetchCmrPages({ const rawCount = Array.isArray(json?.items) ? json.items.length : 0; const page = parseCmrUmm(json, product); readCount += rawCount; - granules.push( - ...page.granules.slice(0, Math.max(0, maxRecords - granules.length)), + const admitted = page.granules.slice( + 0, + Math.max(0, maxRecords - granules.length), ); + granules.push(...admitted); + capped += page.granules.length - admitted.length; hits = Math.max(hits, page.hits); cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null; if ( @@ -65,7 +70,7 @@ export async function fetchCmrPages({ ) break; } - return { granules, truncated: hits > readCount }; + return { granules, truncated: hits > readCount || capped > 0 }; } function finiteOrNull(value) { diff --git a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs index 4925e7bec..c1709b9b2 100644 --- a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs +++ b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs @@ -54,3 +54,45 @@ test('a non-page-multiple limit never returns more admitted records than its bud assert.equal(result.granules.length, 250); assert.equal(result.truncated, true); }); +test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + return Response.json( + { + hits: 400, + items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)), + }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, true); +}); +test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + const items = Array.from({ length: 200 }, (_, i) => + calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`), + ); + return Response.json( + { hits: 400, items }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, false); +});