Merge main into fix/radio-mirror-null-body

Resolve the CHANGELOG conflict by keeping every entry from main and
adding the radio entry on top; the previous main merge (cebd870)
had dropped 19 of main's entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
SaidandClaude Opus 5.5 committed 2026-10-15 17:30:20 +01:00
commit 7056fa113e
36 files changed
+1073 -92

No files matched your search

+49
View File
@@ -5,6 +5,46 @@
get an empty body; anything else that cannot become a response fails that
mirror so the next one is tried.
- Snow flakes sway within a fixed range instead of drifting sideways faster the
longer the app runs; the Wind setting still scales the sway.
- The right rail ignores panels that other CSS hides, such as during an event
view, so they no longer force one-panel-at-a-time layout or collapse visible
panels. Panels the rail collapses itself are still restored.
- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location.
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
their ion-backed providers, retaining ion and per-tile attribution without
widening the Content Security Policy.
- Add New York City traffic cameras from the NYC DOT Traffic Management Center
as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the
camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an
operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's
requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to
`webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each
camera UUID on that same host, so a catalog field cannot steer the proxy
off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest
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.
- Street Level counts unique tiles before ranking so overlapping dateline halves
in polar views do not waste slots in the 16-tile overview budget.
- Directions no longer shows a truncation notice for a route with exactly 200
turns, and a roundabout at the 200th turn keeps its exit distance.
- Weather records keep missing conditions unknown and refuse summaries without a measured temperature.
- 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.
- Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records.
- 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.
@@ -219,6 +259,15 @@
before playback. The id now falls back to a v4 UUID built from
`crypto.getRandomValues`, which the server's lease check accepts.
- 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 only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
fallback order are unchanged.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
+3 -3
View File
@@ -21,19 +21,19 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
| `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 |
### Three 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 and Street View (Maps JavaScript API; add it to the key's API restrictions). **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 and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **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 and Maps JavaScript 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.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three 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 the Street View Static fallback 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 + Maps JavaScript API, 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.
+42 -4
View File
@@ -101,6 +101,10 @@ 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 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
Display > HUD > Layout includes Cyber, also available through the HUD voice
@@ -1090,6 +1094,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
@@ -1173,7 +1180,9 @@ stalled downloads are cancelled. Development and preview use the same handler.
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,
@@ -1202,6 +1211,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.
@@ -1225,6 +1239,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
@@ -1462,7 +1480,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
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.
@@ -4131,7 +4149,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.
@@ -4429,7 +4448,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 only the Map Tiles API and the
Maps JavaScript API (Street View). 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
@@ -4526,6 +4562,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. A catalog is truncated when upstream hits exceed the rows examined or the record cap drops valid granules from the last page; discarded malformed rows do not imply unseen imagery.
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
reject missing, blank or boolean edges instead of interpreting them as zero
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
+4 -1
View File
@@ -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') {
+3
View File
@@ -83,6 +83,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';
+6 -1
View File
@@ -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 {
+28 -6
View File
@@ -14,12 +14,34 @@ 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 + Maps JavaScript API 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);
+191 -1
View File
@@ -1,9 +1,11 @@
import {
googleServerApiKey,
keylessGooglePlacesResponse,
keylessGoogleGeocodeResponse,
} from './google-key.js';
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -25,7 +27,7 @@ let _googleRateLimiter;
*/
export const GOOGLE_DEFAULT_PER_MIN = 120;
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */
function googleRateLimiter() {
if (_googleRateLimiter === undefined)
_googleRateLimiter = makeCostRateLimiter(
@@ -56,6 +58,51 @@ export function validatePlacesCoordinates(searchParams) {
return { ok: true, latitude, longitude };
}
/** 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();
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 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;
return raw;
}
/** Nearby place labels and view-biased text search, with request-time key resolution. */
export function googlePlacesContextProxy({
resolveApiKey = googleServerApiKey,
@@ -293,6 +340,149 @@ 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 }),
);
};
// Gate first, like the Places routes above: a cross-site caller learns
// nothing about this endpoint's method surface.
if (admitSameSite(req, res)) return;
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;
}
// 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 deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS);
const response = await fetchImpl(upstream.toString(), {
redirect: 'error',
signal: deadline,
});
// 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));
} 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: '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 {
+4 -2
View File
@@ -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);
};
@@ -402,7 +404,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: [{
+10 -10
View File
@@ -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;
});
@@ -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)));
});
+28 -16
View File
@@ -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;
}
+23
View File
@@ -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);
}
});
+7 -1
View File
@@ -163,7 +163,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;
}
/**
@@ -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,
);
}
});
+13 -4
View File
@@ -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,
+7 -1
View File
@@ -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;
+11
View File
@@ -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],
]);
});
+204
View File
@@ -0,0 +1,204 @@
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',
// 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);
});
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 Response.json({ 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 Response.json({ 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, []);
});
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',
});
});
+12 -4
View File
@@ -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) {
+18 -7
View File
@@ -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<object>, truncated: boolean }>}
*/
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
}) {
const granules = [];
let hits = 0;
let readCount = 0;
let capped = 0;
let cursor = null;
for (;;) {
const init = { signal };
@@ -47,19 +50,27 @@ 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;
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 (
!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 || capped > 0 };
}
function finiteOrNull(value) {
@@ -0,0 +1,98 @@
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);
});
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);
});
+5 -1
View File
@@ -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,
@@ -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();
}
});
+2 -2
View File
@@ -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 {
+16
View File
@@ -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);
});
}
+14 -9
View File
@@ -42,18 +42,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 }),
+7 -8
View File
@@ -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) {
+1 -1
View File
@@ -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');
@@ -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);
}
});
+9 -6
View File
@@ -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;
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeCatalog, defineTool } from './catalog.js';
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);
});
}
+1
View File
@@ -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('; '));
+57
View File
@@ -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();
});
+5 -1
View File
@@ -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];
+3 -3
View File
@@ -3180,7 +3180,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\/\?/);
@@ -3205,7 +3205,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');
@@ -3218,7 +3218,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,