From 9c638fa2907ec10273a8ed3d3c379bad5ce87da1 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:48:31 -0400 Subject: [PATCH 01/45] refactor(geo): define shared distance contract --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 8 +++ scripts/package-boundaries.json | 20 ++++++- src/annotations/annotationEngine.js | 12 +---- src/annotations/drawMode.js | 11 ++-- src/cockpitCloudEffects.js | 21 ++------ src/data/analystEngine.js | 13 ++--- src/data/naturalEarthRegions.js | 16 ++---- src/data/naturalEarthRegions.test.mjs | 11 ++++ src/data/regionalModel.js | 21 +++----- src/data/routePlausible.js | 40 +++++--------- src/data/routePlausible.test.mjs | 9 +++- src/data/trafficBounds.js | 29 +++------- src/data/trafficBounds.test.mjs | 14 ++--- src/data/transitFeeds.js | 13 ++--- src/geoDistance.js | 70 ++++++++++++++++++++++++ src/geoDistance.test.mjs | 78 +++++++++++++++++++++++++++ src/hud.js | 9 +--- src/layers/bikeshare/model.js | 12 ++--- src/layers/cctv/model.js | 8 +-- src/locations.js | 19 ++----- src/voice/gevActions.js | 23 +++----- 22 files changed, 267 insertions(+), 195 deletions(-) create mode 100644 src/geoDistance.js create mode 100644 src/geoDistance.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 261946f7a..432125bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Share one validated great-circle distance utility across runtime consumers. + Canonical coordinates use `{ lat, lon }`, invalid points stay outside spatial + selections, kilometres and metres share the IUGG mean Earth radius, and + compatibility `haversineKm` exports delegate to the canonical implementation. + - Add Director import previews, validated scene/shot detail drafts and selected-scene JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes before admission and release staged work on cancellation or teardown. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bd7d6cfd7..be65908d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Shared distance checks use `geoDistance.js` with `{ lat, lon }` points and the +IUGG mean spherical Earth radius. `greatCircleKm` is canonical; +`greatCircleMeters` is its exact metre conversion. Missing, non-finite, and +out-of-range points return `Infinity`, so threshold and nearest-item consumers +exclude invalid positions. Older exported `haversineKm` entry points remain as +compatibility wrappers only. Intentional local angular, projection, ellipsoidal +prefilter, and small-viewport approximations remain separate. + Director imports now open a non-mutating preview before Apply. EDIT DETAILS authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports a selected scene or a bounded bundle of explicitly chosen pack files. Bundled diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..632984059 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -246,6 +246,7 @@ "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", "src/sources/rateLimit.js" @@ -303,6 +304,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -713,6 +715,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/flights/controller.js", @@ -786,6 +789,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/aircraft/classification.js", @@ -953,6 +957,7 @@ "src/data/flowMatch.js", "src/data/tomtomTiles.js", "src/data/trafficBounds.js", + "src/geoDistance.js", "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", @@ -984,6 +989,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/geoDistance.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -1021,6 +1027,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/geoDistance.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1115,6 +1122,7 @@ "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", + "src/geoDistance.js", "src/data/neighborhoodPolygons.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", @@ -1208,6 +1216,7 @@ "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", @@ -1834,6 +1843,7 @@ "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/catalog.js", @@ -2224,6 +2234,7 @@ "src/app/surfaceServices.js", "src/data/dataCredits.js", "src/data/geoid.js", + "src/geoDistance.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", @@ -2405,7 +2416,7 @@ }, "regional-source": { "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "modules": ["src/data/regionalModel.js", "src/geoDistance.js"], "external": [] }, "voice-action-schemas": { @@ -2511,6 +2522,7 @@ "src/search/google.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -2533,6 +2545,7 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js" ], "external": [] @@ -2547,6 +2560,7 @@ "server/providers/transitHistory.js", "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/httpBody.js", "src/sources/transitHistory.js", @@ -2561,6 +2575,7 @@ "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], @@ -2580,6 +2595,7 @@ "src/data/geoid.js", "src/data/groundFloor.js", "src/data/gtfsRealtime.js", + "src/geoDistance.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/labelArbiter.js", @@ -2632,6 +2648,7 @@ "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -2675,6 +2692,7 @@ "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", "src/sources/httpBody.js", diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index ccf9cb06a..529dff5cc 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,5 +1,6 @@ import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1530,16 +1531,7 @@ async function fetchRoute(coordPairs, mode, signal, service) { } function greatCircleM(a, b) { - const R = 6371000; - const toRad = (deg) => (deg * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const la1 = toRad(a.lat); - const la2 = toRad(b.lat); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); + return greatCircleMeters(a, b); } function formatDistance(m) { diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index f28a7ae6f..d8cde2ab8 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -18,6 +18,8 @@ * `drawTool.js`. */ +import { greatCircleMeters } from '../geoDistance.js'; + export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']); export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); /** @@ -47,14 +49,7 @@ export function createDrawSession(shape = 'area') { /** Great-circle distance in metres between two {lon, lat} points. */ export function greatCircleM(a, b) { - const R = 6371000; - const toRad = (d) => (d * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.sqrt(Math.min(1, h))); + return greatCircleMeters(a, b); } /** diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 053217810..ccbf31831 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -1,5 +1,6 @@ import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from './geoDistance.js'; import { deriveWeatherEffectProfile, weatherAltitudeFactors, @@ -127,23 +128,9 @@ function compileShader(gl, type, source) { } function greatCircleM(a, b) { - if ( - ![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every( - Number.isFinite, - ) - ) - return Infinity; - const latitudeA = Cesium.Math.toRadians(a.latitude); - const latitudeB = Cesium.Math.toRadians(b.latitude); - const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); - const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); - const haversine = - Math.sin(latitudeDelta / 2) ** 2 + - Math.cos(latitudeA) * - Math.cos(latitudeB) * - Math.sin(longitudeDelta / 2) ** 2; - return ( - 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) + return greatCircleMeters( + { lat: a?.latitude, lon: a?.longitude }, + { lat: b?.latitude, lon: b?.longitude }, ); } diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index a84d6b855..1d2baccaf 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -25,6 +25,7 @@ */ import { pointInRing } from './naturalEarthRegions.js'; +import { greatCircleKm } from '../geoDistance.js'; /** Layers the engine understands, with the fields queries may reference. */ export const ANALYST_LAYERS = { @@ -63,17 +64,9 @@ export const ANALYST_LAYERS = { }, }; -const EARTH_R_KM = 6371; - -/** Great-circle distance in km. */ +/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */ export function haversineKm(lat1, lon1, lat2, lon2) { - const d2r = Math.PI / 180; - const dLat = (lat2 - lat1) * d2r; - const dLon = (lon2 - lon1) * d2r; - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2; - return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a))); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. */ diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 891e7e9a1..c3664e266 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -17,8 +17,8 @@ */ import { createRetryableLoader } from './retryableLoad.js'; +import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js'; -const EARTH_RADIUS_KM = 6371; const toRad = (d) => (d * Math.PI) / 180; /** Spherical-excess ring area (km²) — same family as turf/geojson-area. */ @@ -35,15 +35,6 @@ function ringAreaKm2(ring) { return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2); } -function haversineKm(lon1, lat1, lon2, lat2) { - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); -} - /** * Normalize a query/name for matching: lowercase, strip diacritics and * punctuation, collapse whitespace, strip a leading "the ". @@ -162,7 +153,10 @@ function buildEntries(pack, kind) { polygons, areaKm2, bbox: [minLon, minLat, maxLon, maxLat], - bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat), + bboxDiagonalKm: greatCircleKm( + { lat: minLat, lon: minLon }, + { lat: maxLat, lon: maxLon }, + ), }); } return out; diff --git a/src/data/naturalEarthRegions.test.mjs b/src/data/naturalEarthRegions.test.mjs index 36c1946ad..5dd78adea 100644 --- a/src/data/naturalEarthRegions.test.mjs +++ b/src/data/naturalEarthRegions.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { statSync, readFileSync } from 'node:fs'; import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js'; +import { greatCircleKm } from '../geoDistance.js'; const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url); @@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)' assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000, `Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`); assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km'); + const coordinates = rockies.polygons.flat(); + const minLon = Math.min(...coordinates.map(([lon]) => lon)); + const minLat = Math.min(...coordinates.map(([, lat]) => lat)); + const maxLon = Math.max(...coordinates.map(([lon]) => lon)); + const maxLat = Math.max(...coordinates.map(([, lat]) => lat)); + assert.equal( + rockies.bboxDiagonalKm, + greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }), + 'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points', + ); }); test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => { diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..116697c6f 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -1,3 +1,5 @@ +import { greatCircleMeters } from '../geoDistance.js'; + const MAX_ARTICLES = 5; function cleanText(value, maxLength = 180) { @@ -136,19 +138,8 @@ export function weatherCodeLabel(code) { /** Great-circle distance used to avoid refetching a regional brief every animation frame. */ export function regionalDistanceM(from, to) { - if ( - ![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every( - Number.isFinite, - ) - ) { - return Infinity; - } - const phi1 = (from.latitude * Math.PI) / 180; - const phi2 = (to.latitude * Math.PI) / 180; - const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180; - const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180; - const a = - Math.sin(deltaPhi / 2) ** 2 + - Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; - return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleMeters( + { lat: from?.latitude, lon: from?.longitude }, + { lat: to?.latitude, lon: to?.longitude }, + ); } diff --git a/src/data/routePlausible.js b/src/data/routePlausible.js index a8c0e9259..238c2e6cb 100644 --- a/src/data/routePlausible.js +++ b/src/data/routePlausible.js @@ -10,19 +10,9 @@ * low → its destination should be nearby). */ -const D2R = Math.PI / 180; -const R_KM = 6371; +import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js'; -/** Haversine great-circle distance in km. */ -export function greatCircleKm(lat1, lon1, lat2, lon2) { - const p1 = lat1 * D2R; - const p2 = lat2 * D2R; - const dp = (lat2 - lat1) * D2R; - const dl = (lon2 - lon1) * D2R; - const a = - Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; - return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} +const D2R = Math.PI / 180; function bearingRad(lat1, lon1, lat2, lon2) { const p1 = lat1 * D2R; @@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) { /** Signed cross-track distance (km) of point from the great circle p1→p2. */ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) { - const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM; + const d13 = + greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM; const b13 = bearingRad(lat1, lon1, lat, lon); const b12 = bearingRad(lat1, lon1, lat2, lon2); - return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM; + return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM; } const NEAR_ENDPOINT_KM = 130; @@ -65,14 +56,14 @@ export function routePlausible({ origin = null, destination = null, }) { - const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon); - const haveD = - Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); + const position = { lat: latDeg, lon: lonDeg }; + if (!isGeoPoint(position)) return true; + const haveO = isGeoPoint(origin); + const haveD = isGeoPoint(destination); if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide // (a) Geographic consistency: near an endpoint, or roughly on the path. - const near = (pt) => - greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM; + const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM; let geomOk = (haveO && near(origin)) || (haveD && near(destination)); if (!geomOk && haveO && haveD) { geomOk = @@ -99,17 +90,10 @@ export function routePlausible({ Math.abs(verticalRateMps) > VERT_TREND_MPS ) { if (verticalRateMps > 0) { - if ( - haveO && - greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM - ) + if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local } else { - if ( - haveD && - greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > - LOCAL_AIRPORT_KM - ) + if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local } } diff --git a/src/data/routePlausible.test.mjs b/src/data/routePlausible.test.mjs index dab932c6e..aa59a0c18 100644 --- a/src/data/routePlausible.test.mjs +++ b/src/data/routePlausible.test.mjs @@ -1,14 +1,15 @@ // src/data/routePlausible.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { routePlausible, greatCircleKm } from './routePlausible.js'; +import { greatCircleKm } from '../geoDistance.js'; +import { routePlausible } from './routePlausible.js'; const SFO = { lat: 37.6188, lon: -122.3754 }; const LAX = { lat: 33.9416, lon: -118.4085 }; const JFK = { lat: 40.6413, lon: -73.7781 }; test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => { - const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon); + const d = greatCircleKm(SFO, LAX); assert.ok(Math.abs(d - 543) < 15, `got ${d}`); }); @@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => { latDeg: 51.5, lonDeg: -0.12, origin: { lat: null, lon: null }, destination: null, }), true); + assert.equal(routePlausible({ + latDeg: 95, lonDeg: 0, + origin: SFO, destination: LAX, + }), true); }); diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 4bd80ff32..f41eff6a9 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -13,31 +13,11 @@ * @module data/trafficBounds */ -/** @const {number} Mean Earth radius in km (spherical approximation). */ -const EARTH_RADIUS_KM = 6371; +import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js'; const toRad = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; -/** - * Great-circle distance between two lat/lon points (haversine). - * - * @param {number} lat1 - First point latitude (degrees). - * @param {number} lon1 - First point longitude (degrees). - * @param {number} lat2 - Second point latitude (degrees). - * @param {number} lon2 - Second point longitude (degrees). - * @returns {number} Distance in kilometres. - */ -export function greatCircleKm(lat1, lon1, lat2, lon2) { - const p1 = toRad(lat1); - const p2 = toRad(lat2); - const dp = toRad(lat2 - lat1); - const dl = toRad(lon2 - lon1); - const a = - Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; - return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a))); -} - /** * Initial bearing (radians) from point 1 toward point 2 along the great circle. * @@ -111,10 +91,13 @@ export function deriveFetchCenter({ hitLon, maxPullKm = 12, }) { - if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) { + if (!isGeoPoint({ lat: hitLat, lon: hitLon })) { return { lat: nadirLat, lon: nadirLon, source: 'nadir' }; } - const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon); + const distKm = greatCircleKm( + { lat: nadirLat, lon: nadirLon }, + { lat: hitLat, lon: hitLon }, + ); if (distKm <= maxPullKm) { return { lat: hitLat, lon: hitLon, source: 'hit' }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..3def68111 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -1,8 +1,8 @@ // src/data/trafficBounds.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { greatCircleKm } from '../geoDistance.js'; import { - greatCircleKm, deriveFetchCenter, clampBoundsAroundCenter, } from './trafficBounds.js'; @@ -11,7 +11,7 @@ import { const NADIR = { lat: 30.2672, lon: -97.7431 }; test('greatCircleKm sanity: Austin -> ~5 km north', () => { - const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon); + const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon }); assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`); }); @@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => { test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => { // Hit ~100 km due east of nadir. const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 }; - assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit'); + assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit'); const c = deriveFetchCenter({ nadirLat: NADIR.lat, nadirLon: NADIR.lon, hitLat: hit.lat, hitLon: hit.lon, maxPullKm: 12, }); assert.equal(c.source, 'pulled'); - const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon); + const d = greatCircleKm(NADIR, c); assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`); // Due-east bearing: latitude stays ~constant, longitude moves east but well // short of the hit. @@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => { maxPullKm: 5, }); assert.equal(c.source, 'pulled'); - const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon); + const d = greatCircleKm(NADIR, c); assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`); }); -test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => { - for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) { +test('invalid ground hit falls back to the camera nadir', () => { + for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) { const c = deriveFetchCenter({ nadirLat: NADIR.lat, nadirLon: NADIR.lon, hitLat, hitLon, diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 2858efa62..4ef87971f 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../geoDistance.js'; + /** * @module transitFeeds * @description Registry of keyless, openly licensed GTFS-Realtime @@ -316,7 +318,8 @@ export function getRegisteredTransitFeed(id) { } /** - * Great-circle distance in km. + * Great-circle distance in km. Retained for compatibility with existing + * transit consumers; new shared spatial code should use `greatCircleKm`. * @param {number} aLat * @param {number} aLon * @param {number} bLat @@ -324,13 +327,7 @@ export function getRegisteredTransitFeed(id) { * @returns {number} */ export function haversineKm(aLat, aLon, bLat, bLon) { - const toRad = (deg) => (deg * Math.PI) / 180; - const dLat = toRad(bLat - aLat); - const dLon = toRad(bLon - aLon); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2; - return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h))); + return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon }); } /** diff --git a/src/geoDistance.js b/src/geoDistance.js new file mode 100644 index 000000000..e71b72bb3 --- /dev/null +++ b/src/geoDistance.js @@ -0,0 +1,70 @@ +/** + * Canonical geographic point used by shared spatial utilities. + * + * @typedef {{lat: number, lon: number}} GeoPoint + */ + +/** IUGG mean spherical Earth radius, in kilometres. */ +export const EARTH_RADIUS_KM = 6371.0088; + +const DEGREES_TO_RADIANS = Math.PI / 180; + +/** + * Whether a value is a canonical, finite WGS84-style point in degrees. + * Longitudes are not implicitly wrapped because an out-of-range value usually + * indicates a broken coordinate contract at the caller. + * + * @param {*} point + * @returns {point is GeoPoint} + */ +export function isGeoPoint(point) { + return ( + Number.isFinite(point?.lat) && + point.lat >= -90 && + point.lat <= 90 && + Number.isFinite(point?.lon) && + point.lon >= -180 && + point.lon <= 180 + ); +} + +/** + * Great-circle distance in kilometres between two `{ lat, lon }` points. + * + * `{ lat, lon }` is the canonical shared coordinate representation. Invalid, + * missing, non-finite, or out-of-range coordinates return `Infinity`, which + * keeps invalid points out of threshold and nearest-neighbour selections. + * + * @param {GeoPoint} from + * @param {GeoPoint} to + * @returns {number} + */ +export function greatCircleKm(from, to) { + if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY; + + const latitude1 = from.lat * DEGREES_TO_RADIANS; + const latitude2 = to.lat * DEGREES_TO_RADIANS; + const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS; + const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS; + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(latitude1) * + Math.cos(latitude2) * + Math.sin(longitudeDelta / 2) ** 2; + const bounded = Math.min(1, Math.max(0, haversine)); + return ( + 2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded)) + ); +} + +/** + * Great-circle distance in metres, with the same point and invalid-input + * contract as {@link greatCircleKm}. + * + * @param {GeoPoint} from + * @param {GeoPoint} to + * @returns {number} + */ +export function greatCircleMeters(from, to) { + return greatCircleKm(from, to) * 1000; +} diff --git a/src/geoDistance.test.mjs b/src/geoDistance.test.mjs new file mode 100644 index 000000000..67d6c32fc --- /dev/null +++ b/src/geoDistance.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + EARTH_RADIUS_KM, + greatCircleKm, + greatCircleMeters, + isGeoPoint, +} from './geoDistance.js'; + +const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 }); +const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 }); + +test('great-circle identity and symmetry', () => { + assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0); + assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN)); +}); + +test('great-circle one-degree calibration and canonical units', () => { + const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }); + assert.ok(Math.abs(kilometres - 111.195) < 0.001); + assert.equal( + greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }), + kilometres * 1000, + ); +}); + +test('great-circle handles dateline crossing and antipodal points', () => { + const acrossDateline = greatCircleKm( + { lat: 0, lon: 179 }, + { lat: 0, lon: -179 }, + ); + assert.ok(Math.abs(acrossDateline - 222.39) < 0.01); + assert.ok( + Math.abs( + greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) - + Math.PI * EARTH_RADIUS_KM, + ) < 1e-9, + ); +}); + +test('canonical points require explicit latitude and longitude keys', () => { + assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true); + assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false); + assert.equal(isGeoPoint([10, 80]), false); + assert.equal( + greatCircleKm( + { latitude: 10, longitude: 80 }, + { latitude: 20, longitude: 70 }, + ), + Infinity, + ); +}); + +test('latitude-before-longitude regression rejects a swapped Austin point', () => { + assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON))); + assert.equal( + greatCircleKm( + { lat: AUSTIN.lon, lon: AUSTIN.lat }, + { lat: HOUSTON.lon, lon: HOUSTON.lat }, + ), + Infinity, + ); +}); + +test('invalid, missing, non-finite, and out-of-range points return Infinity', () => { + for (const point of [ + null, + {}, + { lat: 0 }, + { lat: NaN, lon: 0 }, + { lat: 0, lon: Infinity }, + { lat: 90.0001, lon: 0 }, + { lat: 0, lon: -180.0001 }, + ]) { + assert.equal(greatCircleKm(point, AUSTIN), Infinity); + assert.equal(greatCircleMeters(AUSTIN, point), Infinity); + } +}); diff --git a/src/hud.js b/src/hud.js index c3177d808..102cc73c2 100644 --- a/src/hud.js +++ b/src/hud.js @@ -15,6 +15,7 @@ import { applicationServices } from './services/application.js'; */ import * as Cesium from 'cesium'; +import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './locations.js'; import { composeLocalityTag } from './hudLocality.js'; @@ -589,13 +590,7 @@ export class IntelHUD { * @returns {number} Distance in kilometers. */ _haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (deg) => Cesium.Math.toRadians(deg); - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..07a937340 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../../geoDistance.js'; + export function createModel({ state: layerState, services, parts, source }) { /** * Coerce a GBFS boolean field to a native boolean. @@ -52,15 +54,7 @@ export function createModel({ state: layerState, services, parts, source }) { */ function haversineKm(aLat, aLon, bLat, bLon) { - const toRad = (value) => (value * Math.PI) / 180; - const dLat = toRad(bLat - aLat); - const dLon = toRad(bLon - aLon); - const p1 = toRad(aLat); - const p2 = toRad(bLat); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon }); } /** diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js index 48c8cddd4..ae7a84ad9 100644 --- a/src/layers/cctv/model.js +++ b/src/layers/cctv/model.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { greatCircleKm } from '../../geoDistance.js'; import { staticFrameRefreshMs } from '../../data/cctvLod.js'; import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js'; import { DEFAULT_CAMERA_CALIBRATION } from './policy.js'; @@ -298,12 +299,7 @@ export function createModel({ state: layerState, services, parts, source }) { */ function haversineKm(lat1, lon1, lat2, lon2) { - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** diff --git a/src/locations.js b/src/locations.js index d0f90ede6..ef6c01696 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,6 +1,7 @@ import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from './geoDistance.js'; import { viewportBias, placesNearViewRecovery, @@ -1103,18 +1104,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15; */ const PLACE_FALLBACK_HALF_SPAN_KM = 20; -/** Great-circle distance in km (small enough here that the spherical model is fine). */ -function greatCircleKm(lat1, lng1, lat2, lng2) { - const dLat = Cesium.Math.toRadians(lat2 - lat1); - const dLng = Cesium.Math.toRadians(lng2 - lng1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(Cesium.Math.toRadians(lat1)) * - Math.cos(Cesium.Math.toRadians(lat2)) * - Math.sin(dLng / 2) ** 2; - return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a))); -} - /** * Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests. * @@ -1149,10 +1138,8 @@ export function placeFramingViewport( return viewport; const offsetKm = greatCircleKm( - anchorLat, - anchorLng, - metrics.centerLat, - metrics.centerLng, + { lat: anchorLat, lon: anchorLng }, + { lat: metrics.centerLat, lon: metrics.centerLng }, ); if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d607322c8..555ecf70e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2,6 +2,7 @@ import { readLayerLifecycleSummary } from './layerSummary.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from '../geoDistance.js'; import { CITY_POIS, findPoiByName, @@ -3204,8 +3205,10 @@ function centerMatchesSubject(center, subjectPosition) { const subjectLat = Cesium.Math.toDegrees(carto.latitude); const subjectLon = Cesium.Math.toDegrees(carto.longitude); return ( - haversineKm(subjectLat, subjectLon, center.lat, center.lon) <= - SUBJECT_CENTER_TOLERANCE_KM + greatCircleKm( + { lat: subjectLat, lon: subjectLon }, + { lat: center.lat, lon: center.lon }, + ) <= SUBJECT_CENTER_TOLERANCE_KM ); } @@ -3495,7 +3498,7 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) { for (const [cityId, city] of Object.entries(CITY_POIS)) { for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) { const poi = city.pois[poiIndex]; - const distanceKm = haversineKm(latitude, longitude, poi.lat, poi.lon); + const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi); if (distanceKm > maxDistanceKm) continue; matches.push({ name: poi.name, @@ -3510,20 +3513,6 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) { return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5); } -function haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (value) => Cesium.Math.toRadians(value); - const radiusKm = 6371.0088; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * - Math.cos(toRad(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); - return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - function coarseBasemapPlace( viewScale, latitude, From be7187cea7ed771e7461eb2bc41a3310ecb9b4ff Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:02:19 +0700 Subject: [PATCH 02/45] fix(server): bound the terrain-heights cache and its request rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every distinct 5-decimal point was a permanent in-memory entry, a permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a live upstream call. Nothing limited the rate, and coordinates were checked for being finite rather than for being on Earth, so an out-of-range pair was still a valid cache key. Four bounds, each following a pattern this repo already uses: - WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no later stage has to. Poles and the antimeridian still parse. - The point cache holds at most 50,000 entries and evicts oldest-first, the shape `trackBackfillProxies` uses. Sized from measurement: this checkout's cache file held 6,189 entries in 1.0 MB of real use, so the ceiling is eight times that working set. Terrain does not move, so an evicted point costs one refetch and never a wrong answer. - A cache file above 24 MB is not read back, mirroring `rocketLaunchesProxy`. An unmeasurable file is not treated as an oversized one: without stat, readFile decides, exactly as before. - The route carries an always-on limiter of 90/min per client, 270 global — the `/api/overpass` band, and an order of magnitude above measured demand (a full test:track run drove 10 requests, peak 8/min). The ceilings are constructor options so they can be exercised as behaviour rather than asserted as constants; production passes none. They are destructured in the body because `proxyErrorResponses.test.mjs` slices these proxies to the first column-zero closing brace. --- CHANGELOG.md | 11 ++ docs/CURRENT-STATE.md | 11 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 4 +- server/providers/terrain.js | 93 +++++++++++- src/data/terrainHeightsProxy.js | 8 ++ src/proxyErrorResponses.test.mjs | 4 + src/terrainHeightsBounds.test.mjs | 232 ++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 src/terrainHeightsBounds.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f59c0c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate and only a finite-number check on the coordinates. + Coordinates outside WGS-84 range are now rejected with `400`, the point cache + holds at most 50,000 entries and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..adf2e7995 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2064,6 +2064,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > tunings. Ambient overlap includes each sprite's own rendered extent. The > gated AIS, CCTV, and satellite passes retain an active-emphasis count so a > settled dim contact always completes its release after tracking ends. +> - **Terrain-height bounds:** because every distinct 5-decimal point is its own +> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own +> upstream call, four limits bound what a caller can grow. Coordinates outside +> WGS-84 range are rejected with `400` at parse (finite is not the same as on +> Earth); the point cache holds at most 50,000 entries, evicting oldest-first +> — terrain does not move, so an evicted point costs one refetch and never a +> wrong answer; a cache file above 24 MB is not read back into memory (the +> ceiling `rocketLaunchesProxy` already applies); and the route carries an +> always-on limiter of 90 requests/minute/client, 270 global. That cap is an +> order of magnitude above measured demand — a full `npm run test:track` run +> drove 10 requests, peaking at 8/min. > - **Terrain-height resilience:** `/api/terrain/heights` caches canonical > 5-decimal points individually, reconstructs reordered/overlapping batches > in exact request order, and refreshes only missing or stale points. Upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..ca43cbbb3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/terrainHeightsBounds.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..d12fc42ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -101,7 +101,9 @@ "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", - "src/data/terrainHeightsProxy.js" + "src/data/terrainHeightsProxy.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index e58a63ea5..ca9442db1 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -8,6 +8,7 @@ import { terrainPointKey, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -20,11 +21,27 @@ import { * Only missing/stale points go upstream; the response is rebuilt in exact * request order. Larger requests are chunked sequentially, and one failing * chunk does not discard the chunks that resolved. + * + * Bounds, because every distinct 5dp point is its own key: coordinates must be + * in WGS-84 range (rejected at parse), the point cache has an entry ceiling + * with oldest-first eviction, an over-large cache file is not read back, and + * the route carries an always-on per-client limiter. */ -export function terrainHeightsProxy() { +export function terrainHeightsProxy(options = {}) { + // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` + // extracts these proxies by slicing to the first closing brace in column + // zero, so a multi-line parameter list would cut the function in half. + // Overridable so the ceilings can be exercised as behaviour rather than + // asserted as constants; production constructs this with no arguments. + const { + cacheMaxEntries = 50_000, + cacheMaxDiskBytes = 24 * 1024 * 1024, + cachePath, + } = options; const TTL_MS = 30 * 24 * 3600_000; - const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + const CACHE_PATH = + cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json'); + const CACHE_DIR = path.dirname(CACHE_PATH); // Sized against measured upstream latency, not the documented page cap. // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows @@ -34,9 +51,50 @@ export function terrainHeightsProxy() { // that range. const UPSTREAM_CHUNK = 64; const MAX_POINTS = 2000; + /** + * Entry ceiling for the point cache, and with it the file written from it. + * + * Every distinct 5dp point is a separate key, so without a ceiling the map + * — and the JSON dumped from it verbatim — grow for as long as a caller + * supplies new coordinates. Measured against real use rather than guessed: + * this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in + * 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is + * eight times that working set and ~8 MB on disk, comfortably inside the + * 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file. + */ + const CACHE_MAX_ENTRIES = cacheMaxEntries; + /** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */ + const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes; + /** + * Requests/min/client. Terrain is keyless and free, so this guards the point + * cache and the upstream community service rather than a bill. Sized from + * measurement: across a full `npm run test:track` run — the heaviest + * exercise this app has — the route served 10 requests, peaking at 8/min. + * 90/min is an order of magnitude above that, and the band `/api/overpass` + * and `/api/military-installations` already use. + */ + const RATE_PER_MIN = 90; + const allow = makeRateLimiter({ + windowMs: 60_000, + max: RATE_PER_MIN, + globalMax: RATE_PER_MIN * 3, + }); /** @type {Map} keyed by canonical 5dp lon/lat. */ const mem = new Map(); + + /** + * Drop the oldest entries until the map is back inside its ceiling. + * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. + * Terrain does not move, so evicting an old point costs one refetch, never + * a wrong answer. + */ + function evictOverflow() { + if (mem.size <= CACHE_MAX_ENTRIES) return; + const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) + mem.delete(byAge[i][0]); + } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); let diskLoaded = false; @@ -47,6 +105,21 @@ export function terrainHeightsProxy() { if (diskLoaded) return; diskLoaded = true; try { + // A cache file past the ceiling is not read back into memory: loading it + // would reinstate exactly the unbounded map the ceiling exists to stop. + // The next flush rewrites a bounded one. + // + // "Cannot measure it" is not "too large": if stat is unavailable, fall + // through and let readFile decide, so an absent file behaves exactly as + // it did before this guard existed. + let sizeBytes = null; + try { + sizeBytes = (await fsp.stat(CACHE_PATH)).size; + } catch { + sizeBytes = null; + } + if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES) + throw new Error('cache file too large'); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const pointEntries = parsed?.version === 2 && @@ -87,6 +160,7 @@ export function terrainHeightsProxy() { } diskDirty = mem.size > 0; } + evictOverflow(); } catch { /* no disk cache yet */ } @@ -163,6 +237,14 @@ export function terrainHeightsProxy() { res.end(JSON.stringify(bodyObj)); }; try { + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); @@ -187,7 +269,10 @@ export function terrainHeightsProxy() { fetchMissing: fetchMissingSingleFlight, ttlMs: TTL_MS, }); - if (outcome.cacheChanged) diskDirty = true; + if (outcome.cacheChanged) { + evictOverflow(); + diskDirty = true; + } if (outcome.upstreamError) { console.warn( '[terrain-heights-proxy] refresh incomplete' + diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..11b8e0037 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -18,6 +18,13 @@ function defaultSleep(ms) { /** * Parse the proxy's `points=lon,lat;...` query parameter. + * + * Finite is not the same as on Earth. A coordinate outside WGS-84 range can + * never resolve to a height, but it is still a distinct 5dp cache key, so + * accepting it meant an out-of-range point became a permanent cache entry and + * a live upstream call. Range is checked here, at the edge, so no later stage + * has to. + * * @param {string|null|undefined} raw * @returns {Array<[number, number]>|null} */ @@ -36,6 +43,7 @@ export function parseTerrainPoints(raw) { const lon = Number(parts[0]); const lat = Number(parts[1]); if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; points.push([lon, lat]); } return points; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..9cc1ff9c7 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; +import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] .map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8')) @@ -22,6 +23,9 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, + // The rate-limited proxies reach for these; give them the real ones so a + // fixture request is throttled exactly as production would be. + makeRateLimiter, clientKey, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs new file mode 100644 index 000000000..1a0c80290 --- /dev/null +++ b/src/terrainHeightsBounds.test.mjs @@ -0,0 +1,232 @@ +// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates. +// +// Every distinct 5dp lon/lat is its own cache key, its own permanent line in +// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things +// bound that: WGS-84 range at parse, an entry ceiling on the map, a size +// ceiling on the file that is read back, and a per-client request limiter. +// +// These cases drive the real exported route handler. `fetch` is stubbed per +// test and restored after, so a case that reaches upstream fails loudly. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; +import { terrainHeightsProxy } from '../server/providers/terrain.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +function stubFetch(t, impl) { + const original = globalThis.fetch; + globalThis.fetch = impl; + t.after(() => { + globalThis.fetch = original; + }); +} + +/** + * Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order. + * Records which point strings were actually asked for, so a test can assert on + * cache misses rather than on internal state. + */ +function stubTerrainUpstream(t) { + const asked = []; + stubFetch(t, async (url) => { + const points = decodeURIComponent( + new URL(String(url)).searchParams.get('points') || '', + ) + .split(';') + .filter(Boolean); + asked.push(...points); + return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) }); + }); + return asked; +} + +test('a coordinate off the globe is not a cache key', () => { + // Finite but not on Earth — the old guard accepted every one of these. + for (const raw of [ + '0,91', + '0,-91', + '181,0', + '-181,0', + '1e9,1e9', + '0,90.00001', + ]) + assert.equal(parseTerrainPoints(raw), null, raw); + + // The poles and the antimeridian are real places and must still parse. + for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26']) + assert.ok(parseTerrainPoints(raw), raw); +}); + +test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => { + let upstreamCalls = 0; + stubFetch(t, async () => { + upstreamCalls += 1; + return new Response('[]', { status: 200 }); + }); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + const refused = await request(route, { url: '/?points=0,91' }); + assert.equal(refused.statusCode, 400); + assert.equal( + upstreamCalls, + 0, + 'an impossible coordinate must not cost an upstream call', + ); +}); + +test('one out-of-range point rejects the batch it is hidden in', async (t) => { + stubFetch(t, async () => new Response('[]', { status: 200 })); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const mixed = await request(route, { + url: '/?points=-97.74,30.26;0,999;-97.75,30.27', + }); + assert.equal(mixed.statusCode, 400); +}); + +test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { + const asked = stubTerrainUpstream(t); + // A ceiling of 3 exercises the same code path 50,000 does, in four requests. + const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( + '/api/terrain/heights', + ); + + const point = (i) => `-97.7${i}000,30.26000`; + for (let i = 1; i <= 4; i += 1) + await request(route, { url: `/?points=${point(i)}` }); + assert.equal(asked.length, 4, 'four distinct points, four upstream lookups'); + + // The newest point is still cached... + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 4, 'a cached point costs nothing'); + + // ...and the oldest was evicted, so it costs one refetch — never a wrong + // answer, because terrain does not move. + await request(route, { url: `/?points=${point(1)}` }); + assert.equal(asked.length, 5, 'the evicted point was asked for again'); + assert.equal(asked[4], point(1)); +}); + +test('an over-large cache file is not read back into memory', async (t) => { + const asked = stubTerrainUpstream(t); + const point = '-97.74000,30.26000'; + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + const cachePath = path.join(dir, 'terrain-heights.json'); + t.after(() => rm(dir, { recursive: true, force: true })); + + // A cache file holding the answer, but larger than the ceiling allows. + const padded = { + version: 2, + points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } }, + _pad: 'x'.repeat(4096), + }; + await writeFile(cachePath, JSON.stringify(padded), 'utf8'); + + const route = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }), + ).get('/api/terrain/heights'); + await request(route, { url: `/?points=${point}` }); + assert.deepEqual( + asked, + [point], + 'the oversized file was skipped, so the point was a miss', + ); + + // The same file under a ceiling that admits it IS used, which is what proves + // the previous assertion came from the size guard and not a broken path. + const admitted = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }), + ).get('/api/terrain/heights'); + await request(admitted, { url: `/?points=${point}` }); + assert.deepEqual(asked, [point], 'a cache within the ceiling is still read'); +}); + +test('the route refuses a flood of requests from one client', async (t) => { + stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + let refusedAt = 0; + for (let i = 1; i <= 95; i += 1) { + // A different point each time: distinct keys are the growth path. + const lon = (-97.7 - i / 1000).toFixed(5); + const answer = await request(route, { url: `/?points=${lon},30.26` }); + if (answer.statusCode === 429) { + refusedAt = i; + break; + } + } + assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); +}); + +test('a second client keeps its own quota', async (t) => { + stubFetch(t, async () => + Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), + ); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + for (let i = 0; i < 90; i += 1) + await request(route, { + url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`, + remoteAddress: '198.51.100.7', + }); + const exhausted = await request(route, { + url: '/?points=-99.5,30.26', + remoteAddress: '198.51.100.7', + }); + assert.equal(exhausted.statusCode, 429); + + const neighbour = await request(route, { + url: '/?points=-99.6,30.26', + remoteAddress: '198.51.100.8', + }); + assert.notEqual(neighbour.statusCode, 429); +}); From b681d094ca0e2d0ea0d2cea0b0a6636410ea5e94 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 10:43:30 +0800 Subject: [PATCH 03/45] docs: correct ADSBDB cache persistence note --- src/layers/flights/policy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..87e4617ee 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -213,8 +213,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25; // adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 // concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart // (≤5/s — adsbdb is a free community API; the dev-server proxy additionally -// caches per-key on disk forever, negative results included, so repeat -// sessions never re-hit adsbdb). Each key is requested at most once per +// caches successful and 404 responses per key for 24 hours, so later sessions +// may refresh them). Each key is requested at most once per // session. Priority jobs (tracked plane, model-eligible planes) jump the // queue; the ambient fleet sweep (below) fills the back at poll cadence. // --------------------------------------------------------------------------- From ff799d1520087c01c6b9f3bd88bf2e3a4d6064cc Mon Sep 17 00:00:00 2001 From: Sunil Date: Thu, 8 Oct 2026 21:13:16 +0530 Subject: [PATCH 04/45] fix(server): refuse cross-site requests on the terrain-heights route Follow-up to #686 requested on #923. The route's per-client limiter keys on the socket address, which for a cross-site is the visitor's own browser, so any web page could fill the bounded cache with junk keys and spend the visitor's 90/min quota, leaving the app's own terrain lookups on 429. Gate the route with admitSameSite before the limiter, like the Places routes. Co-authored-by: daikaginza --- scripts/package-boundaries.json | 4 ++- server/providers/terrain.js | 9 +++++- src/proxyErrorResponses.test.mjs | 3 ++ src/terrainHeightsBounds.test.mjs | 46 +++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e1af1ed50..610986341 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,9 @@ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js", "server/providers/common/rate-limit.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index ca9442db1..b2615094e 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -9,6 +9,7 @@ import { validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -25,7 +26,9 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; * Bounds, because every distinct 5dp point is its own key: coordinates must be * in WGS-84 range (rejected at parse), the point cache has an entry ceiling * with oldest-first eviction, an over-large cache file is not read back, and - * the route carries an always-on per-client limiter. + * the route carries an always-on per-client limiter. The route also refuses + * cross-site browser requests, so another web page cannot use the visitor's + * browser to fill the cache or spend the visitor's own rate-limit quota. */ export function terrainHeightsProxy(options = {}) { // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` @@ -231,6 +234,10 @@ export function terrainHeightsProxy(options = {}) { const installMiddleware = (server) => { server.middlewares.use('/api/terrain/heights', async (req, res) => { + // Refuse cross-site browser requests (an or fetch from another + // site) before the limiter, cache or upstream see them. Same-origin app + // requests and non-browser loopback callers (MCP panel_request) pass. + if (admitSameSite(req, res)) return; const send = (status, bodyObj) => { if (res.headersSent) return; res.writeHead(status, { 'Content-Type': 'application/json' }); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 9cc1ff9c7..35845ee88 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -26,6 +26,9 @@ function fixture(name, overrides = {}, preview = false) { // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, + // terrain.js gates cross-site requests first; fixture requests carry no + // browser headers, so admit them like a loopback tool. + admitSameSite: () => false, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 1a0c80290..83edc262d 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -29,10 +29,18 @@ function install(plugin) { return routes; } -function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { +function request( + handler, + { url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, +) { return new Promise((resolve, reject) => { const headers = new Map(); - const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const req = { + method: 'GET', + url, + headers: reqHeaders, + socket: { remoteAddress }, + }; const res = { statusCode: 200, headersSent: false, @@ -207,6 +215,40 @@ test('the route refuses a flood of requests from one client', async (t) => { assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); }); +test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { + const asked = stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const host = 'localhost:5173'; + const refused = [ + { host, 'sec-fetch-site': 'cross-site' }, // from any web page + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://evil.example' }, // fetch from another page + { host, origin: 'null' }, // sandboxed frame + { host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy + ]; + // More refused requests than the 90/min quota, each a distinct new key. + for (let i = 0; i < 100; i += 1) { + const reqHeaders = refused[i % refused.length]; + const answer = await request(route, { + url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`, + reqHeaders, + }); + assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders)); + } + assert.deepEqual(asked, [], 'nothing reached upstream or the cache'); + + // The visitor's own app still has its full quota afterwards, and a + // loopback tool that sends no browser headers is admitted too. + const app = await request(route, { + url: '/?points=-97.74,30.26', + reqHeaders: { host, 'sec-fetch-site': 'same-origin' }, + }); + assert.equal(app.statusCode, 200); + const tool = await request(route, { url: '/?points=-97.75,30.26' }); + assert.equal(tool.statusCode, 200); + assert.equal(asked.length, 2); +}); + test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), From e38672b45d7660381afbb36e37af68ddad595157 Mon Sep 17 00:00:00 2001 From: Sunil Date: Fri, 9 Oct 2026 14:38:41 +0530 Subject: [PATCH 05/45] docs: document terrain same-site gate --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a155d92..c5b572bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,10 @@ answer, since terrain does not move. Ordinary use is unaffected: a full `test:track` run drives 10 requests, peaking at 8/min. +- `/api/terrain/heights` refuses cross-site browser requests before rate + limiting, cache access or upstream work. Same-origin app requests and + non-browser local callers remain supported. + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c7afdd24c..7c260baff 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1103,7 +1103,10 @@ plugin order, server-key selection, validation, disk caches, budgets, retries and stale/error responses remain unchanged. Each has a Node-only package entry under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile math and GBFS source rules are available under `gods-eye-view/sources/`. -The browser layers and their rendering remain in their existing modules. +The `/api/terrain/heights` route refuses cross-site browser requests before +rate limiting, cache access or upstream work. Same-origin app requests and +non-browser local callers remain supported in development and preview. The +browser layers and their rendering remain in their existing modules. ## Terrain height cache bound From 2110d06e36eb7b7ed0a6cf595257c414ecde2587 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:45 -0700 Subject: [PATCH 06/45] feat(tools): count and summarize every match in the area tools aircraft_in_area and vessels_in_area return at most 200 rows, so a client asked which operators dominate an area, or the average altitude there, could only estimate from a truncated sample. Both tools now take an optional group_by (aircraft: operator, origin_country, type_code; ships: type, destination) and stats (airborne altitude and speed for aircraft, speed for ships). Each is computed over every match before rows are capped. Breakdowns list the ten largest groups plus other and unknown counts, and the summary names the three largest. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 6 ++- docs/TOOLS.md | 4 +- scripts/format-scope.json | 1 + src/tools/queries/aviation.js | 37 +++++++++++++- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++ src/tools/queries/maritime.js | 22 +++++++- src/tools/queries/maritime.test.mjs | 25 +++++++++ src/tools/results.js | 69 +++++++++++++++++++++++++ src/tools/results.test.mjs | 78 +++++++++++++++++++++++++++++ 10 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 src/tools/results.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..0a5f41549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a + client can ask which operators or ship types dominate an area, or the average + altitude or speed, over every match instead of estimating from the returned + rows. + - 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 274294b5d..520bb9207 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -93,7 +93,11 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Voice offers the same queries next to its app +lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +count every match by a field (`group_by`: operator, country or type for +aircraft; type or destination for ships; the ten largest groups, then other and +unknown counts) and summarize airborne altitude and speed (`stats`: count, min, +median, mean, max). Both cover all matches, not just the rows returned. 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). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..f3b86f7a3 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | -| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match | | `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | | `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | -| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | +| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..f8c056177 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -941,6 +941,7 @@ "scripts/qa-context-arrivals.mjs", "src/tools/catalog.test.mjs", "src/tools/area.test.mjs", + "src/tools/results.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", "src/tools/localServer.test.mjs", diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..e3aca82c4 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -11,9 +11,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -32,6 +35,11 @@ const MILITARY_SCHEMA = Object.freeze({ description: 'Use the military aircraft feed instead of the general feed.', }); const MAX_TRACK_POINTS = 200; +const GROUP_LABELS = Object.freeze({ + operator: 'operator', + origin_country: 'country', + type_code: 'type', +}); const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -82,7 +90,10 @@ export const aircraftInArea = defineTool({ title: 'Aircraft in an area', description: 'Aircraft currently reported inside an area, nearest the center first. ' + - 'Optionally military aircraft only, airborne only, or within an altitude band.', + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + + 'group_by counts every matched aircraft by operator, country or type, and ' + + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + + 'just the rows returned.', inputSchema: { type: 'object', properties: { @@ -91,6 +102,16 @@ export const aircraftInArea = defineTool({ airborne_only: { type: 'boolean' }, min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + group_by: { + type: 'string', + enum: Object.keys(GROUP_LABELS), + description: 'Count matched aircraft by this field, largest first.', + }, + stats: { + type: 'boolean', + description: + 'Summarize airborne altitude and speed over matched aircraft.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -126,9 +147,11 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${rows.length} ${kind} in ${area.label}` + + (groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { @@ -136,6 +159,18 @@ export const aircraftInArea = defineTool({ layers: [args.military ? 'military' : 'flights'], }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { + stats: { + altitude_m: numericSummary( + rows.filter((row) => !row.on_ground), + 'altitude_m', + ), + speed_mps: numericSummary(rows, 'speed_mps'), + }, + } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..618085b00 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,60 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('aircraft in an area can be counted by operator over every match', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { operator: 'United' }), + record('a2', 37.61, -122.3, { operator: 'Delta' }), + record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }), + record('a4', 37.63, -122.3, { onGround: true, baroAltitudeM: 0 }), + record('a5', 51.5, -0.1, { operator: 'British Airways' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'operator', + stats: true, + limit: 1, + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + ); + assert.equal(result.data.returned, 1); + assert.deepEqual(result.data.breakdown, { + field: 'operator', + groups: [ + { value: 'United', count: 2 }, + { value: 'Delta', count: 1 }, + ], + other_count: 0, + unknown_count: 1, + }); + // Altitude covers airborne aircraft only; speed covers every match. + assert.deepEqual(result.data.stats.altitude_m, { + count: 3, + min: 3000, + median: 10000, + mean: 7666.7, + max: 10000, + }); + assert.equal(result.data.stats.speed_mps.count, 4); +}); + +test('aircraft answers carry no breakdown or stats unless asked', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal('breakdown' in result.data, false); + assert.equal('stats' in result.data, false); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }), + /must be one of/, + ); +}); diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5b64f2826..a918c1f52 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -12,9 +12,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -88,7 +91,9 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type.', + 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'ship by type or destination, and stats summarizes their speed; both ' + + 'cover all matches, not just the rows returned.', inputSchema: { type: 'object', properties: { @@ -99,6 +104,15 @@ export const vesselsInArea = defineTool({ maxLength: 40, description: 'Vessel type to match, such as "tanker" or "cargo".', }, + group_by: { + type: 'string', + enum: ['type', 'destination'], + description: 'Count matched vessels by this field, largest first.', + }, + stats: { + type: 'boolean', + description: 'Summarize speed over matched vessels.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -119,13 +133,19 @@ export const vesselsInArea = defineTool({ .map((record) => vesselRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.type ? `${args.type} vessel` : 'vessel'; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${countNoun(rows.length, kind)} in ${area.label}` + + (groups ? breakdownPhrase(groups, args.group_by) : '') + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['ais-live-vessels'] }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 785e38223..d1c43e60a 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -224,3 +224,28 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('lat'), false); assert.equal(lookup.has('radius_km'), false); }); + +test('vessels in an area can be counted by destination with a speed summary', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'destination', + stats: true, + }); + assert.equal( + result.summary, + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + ); + assert.deepEqual(result.data.breakdown, { + field: 'destination', + groups: [{ value: 'OAKLAND', count: 1 }], + other_count: 0, + unknown_count: 1, + }); + assert.deepEqual(result.data.stats, { + speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, + }); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..b4a24c72a 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -22,6 +22,75 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { }; } +/** Most groups a breakdown lists before counting the rest as other. */ +export const MAX_GROUPS = 10; + +/** + * Count rows by one text field over every row, not just those returned. + * Values group case-insensitively and keep their first spelling; rows + * without a value are counted as unknown. + */ +export function breakdown(rows, field) { + const groups = new Map(); + let unknown = 0; + for (const row of rows) { + const value = typeof row[field] === 'string' ? row[field].trim() : ''; + if (!value) { + unknown += 1; + continue; + } + const key = value.toLowerCase(); + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { value, count: 1 }); + } + const sorted = [...groups.values()].sort( + (a, b) => b.count - a.count || a.value.localeCompare(b.value), + ); + const listed = sorted.slice(0, MAX_GROUPS); + return { + field, + groups: listed, + other_count: rows.length - unknown - sum(listed.map((g) => g.count)), + unknown_count: unknown, + }; +} + +/** Minimum, median, mean and maximum of one numeric field, or null. */ +export function numericSummary(rows, field) { + const values = rows + .map((row) => row[field]) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!values.length) return null; + const middle = values.length >> 1; + const median = + values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2; + const round1 = (value) => Number(value.toFixed(1)); + return { + count: values.length, + min: values[0], + median: round1(median), + mean: round1(sum(values) / values.length), + max: values.at(-1), + }; +} + +/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +export function breakdownPhrase(result, label) { + if (!result.groups.length) return ''; + const top = result.groups + .slice(0, 3) + .map((group) => `${group.value} (${group.count})`); + return `; most by ${label}: ${top.join(', ')}`; +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0); +} + /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { return Number.isFinite(ms) ? new Date(ms).toISOString() : null; diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs new file mode 100644 index 000000000..ba67b9f64 --- /dev/null +++ b/src/tools/results.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MAX_GROUPS, + breakdown, + breakdownPhrase, + numericSummary, +} from './results.js'; + +test('a breakdown counts every row, case-insensitively, largest first', () => { + const rows = [ + { operator: 'Delta' }, + { operator: 'United' }, + { operator: ' delta ' }, + { operator: null }, + { operator: '' }, + { operator: 'American' }, + { operator: 'United' }, + { operator: 'DELTA' }, + ]; + assert.deepEqual(breakdown(rows, 'operator'), { + field: 'operator', + groups: [ + { value: 'Delta', count: 3 }, + { value: 'United', count: 2 }, + { value: 'American', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); +}); + +test('groups past the limit are counted as other', () => { + const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({ + type: `T${String(index).padStart(2, '0')}`, + })); + rows.push({ type: 'T00' }); + const result = breakdown(rows, 'type'); + assert.equal(result.groups.length, MAX_GROUPS); + assert.deepEqual(result.groups[0], { value: 'T00', count: 2 }); + assert.equal(result.other_count, 3); + assert.equal( + result.groups.reduce((total, group) => total + group.count, 0) + + result.other_count + + result.unknown_count, + rows.length, + ); +}); + +test('a numeric summary skips missing values', () => { + const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}]; + assert.deepEqual(numericSummary(rows, 'v'), { + count: 4, + min: 1, + median: 3.5, + mean: 4.5, + max: 10, + }); + assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5); + assert.equal(numericSummary([{ v: null }], 'v'), null); + assert.equal(numericSummary([], 'v'), null); +}); + +test('the breakdown phrase names the three largest groups', () => { + const result = { + groups: [ + { value: 'A', count: 4 }, + { value: 'B', count: 3 }, + { value: 'C', count: 2 }, + { value: 'D', count: 1 }, + ], + }; + assert.equal( + breakdownPhrase(result, 'type'), + '; most by type: A (4), B (3), C (2)', + ); + assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); +}); From 603f428397f61ddd06893b3178081398f5f9c50c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:35:24 -0700 Subject: [PATCH 07/45] fix(tools): say when a breakdown's field is missing from the feed OpenSky, the default civil feed, reports no operator or type code, so a breakdown by either put every aircraft under unknown while the summary said nothing. The summary now says "no operator reported" in that case, and otherwise appends the unknown count after the largest groups. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 +++-- src/tools/queries/aviation.js | 4 +++- src/tools/queries/aviation.test.mjs | 2 +- src/tools/queries/maritime.test.mjs | 2 +- src/tools/results.js | 13 ++++++++++--- src/tools/results.test.mjs | 12 +++++++++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520bb9207..f9e9c02a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -96,8 +96,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts) and summarize airborne altitude and speed (`stats`: count, min, -median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +unknown counts; the summary says when a feed reports none of the field) and +summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +Both cover all matches, not just the rows returned. 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). diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index e3aca82c4..23652f211 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -105,7 +105,9 @@ export const aircraftInArea = defineTool({ group_by: { type: 'string', enum: Object.keys(GROUP_LABELS), - description: 'Count matched aircraft by this field, largest first.', + description: + 'Count matched aircraft by this field, largest first. Feeds that do not ' + + 'report the field count those aircraft as unknown.', }, stats: { type: 'boolean', diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 618085b00..b6b6e3497 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -409,7 +409,7 @@ test('aircraft in an area can be counted by operator over every match', async () }); assert.equal( result.summary, - '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.', ); assert.equal(result.data.returned, 1); assert.deepEqual(result.data.breakdown, { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d1c43e60a..662e5bdf2 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -237,7 +237,7 @@ test('vessels in an area can be counted by destination with a speed summary', as }); assert.equal( result.summary, - '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.', ); assert.deepEqual(result.data.breakdown, { field: 'destination', diff --git a/src/tools/results.js b/src/tools/results.js index b4a24c72a..528f59ea5 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -78,13 +78,20 @@ export function numericSummary(rows, field) { }; } -/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +/** + * "; most by operator: Delta (12), United (9), American (7); 4 unknown", or + * "; no operator reported" when no row carries the field. + */ export function breakdownPhrase(result, label) { - if (!result.groups.length) return ''; + if (!result.groups.length) + return result.unknown_count ? `; no ${label} reported` : ''; const top = result.groups .slice(0, 3) .map((group) => `${group.value} (${group.count})`); - return `; most by ${label}: ${top.join(', ')}`; + const unknown = result.unknown_count + ? `; ${result.unknown_count} unknown` + : ''; + return `; most by ${label}: ${top.join(', ')}${unknown}`; } function sum(values) { diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs index ba67b9f64..2e480afcd 100644 --- a/src/tools/results.test.mjs +++ b/src/tools/results.test.mjs @@ -63,6 +63,7 @@ test('a numeric summary skips missing values', () => { test('the breakdown phrase names the three largest groups', () => { const result = { + unknown_count: 0, groups: [ { value: 'A', count: 4 }, { value: 'B', count: 3 }, @@ -74,5 +75,14 @@ test('the breakdown phrase names the three largest groups', () => { breakdownPhrase(result, 'type'), '; most by type: A (4), B (3), C (2)', ); - assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); + assert.equal( + breakdownPhrase({ ...result, unknown_count: 5 }, 'type'), + '; most by type: A (4), B (3), C (2); 5 unknown', + ); + // A feed that never reports the field says so instead of staying silent. + assert.equal( + breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'), + '; no operator reported', + ); + assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), ''); }); From 1371a867137aebcdcd488213a0b2ce7b3e42bfa9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:47 -0700 Subject: [PATCH 08/45] docs: say the area breakdowns reach MCP clients only Voice leaves aircraft_in_area and vessels_in_area out, so the changelog no longer suggests spoken questions gain breakdowns. It also stops leading with operators, which the default civil feed never reports. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++---- docs/CURRENT-STATE.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f41549..b918e89cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a - client can ask which operators or ship types dominate an area, or the average - altitude or speed, over every match instead of estimating from the returned - rows. +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them. - 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 f9e9c02a7..bdc9573b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,7 +98,8 @@ count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; the summary says when a feed reports none of the field) and summarize airborne altitude and speed (`stats`: count, min, median, mean, max). -Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +Both cover all matches, not just the rows returned; voice leaves these two +tools out, so they reach MCP clients only. 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). From 003b92f64a60af2f4681863f974a777bdc89ea2e Mon Sep 17 00:00:00 2001 From: VG31OP Date: Tue, 13 Oct 2026 16:57:55 +0530 Subject: [PATCH 09/45] fix(satellites): reject TLE pairs with mismatched catalog numbers (#906, #936) --- CHANGELOG.md | 7 ++++--- src/sources/tle.js | 15 +++++++++++---- src/tools/queries/space.test.mjs | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..83b32baaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,9 +95,10 @@ ## [Unreleased] -- Keep later satellites when a TLE catalog entry is missing an orbital line. - The parser skips the incomplete set and resumes at the next complete named - set, instead of losing the rest of the group (#906). +- Keep later satellites when a TLE catalog entry is missing an orbital line or + pairs lines with mismatched NORAD catalog numbers. The parser skips corrupt sets + and resumes at the next complete named set, instead of losing the rest of the + group (#906, #936). - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..8f97e4eda 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -13,7 +13,14 @@ export function parseTleText(text) { const name = lines[i]; const line1 = lines[i + 1]; const line2 = lines[i + 2]; - if (line1.startsWith('1 ') && line2.startsWith('2 ')) { + const cat1 = tleCatalogNumber(line1); + const cat2 = tleCatalogNumber(line2); + if ( + line1.startsWith('1 ') && + line2.startsWith('2 ') && + cat1 !== null && + cat1 === cat2 + ) { result.push({ name, line1, line2 }); i += 2; } @@ -21,8 +28,8 @@ export function parseTleText(text) { return result; } -/** The NORAD catalog number from TLE line 1, or null. */ -export function tleCatalogNumber(line1) { - const number = Number.parseInt(String(line1).slice(2, 7), 10); +/** The NORAD catalog number from a TLE line, or null. */ +export function tleCatalogNumber(line) { + const number = Number.parseInt(String(line).slice(2, 7), 10); return Number.isInteger(number) ? number : null; } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index f6c2a8df8..2b2586b45 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -51,6 +51,23 @@ for (const missingLine of [1, 2]) { }); } +test('TLE parsing rejects pairs with mismatched catalog numbers and resumes', () => { + const text = `SAT A +1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994 +2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000 +SAT B +1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993 +2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000 +`; + const entries = parseTleText(text); + assert.deepEqual( + entries.map((entry) => entry.name), + ['SAT B'], + ); + assert.equal(tleCatalogNumber(entries[0].line1), 48274); + assert.equal(tleCatalogNumber(entries[0].line2), 48274); +}); + test('the next pass defaults to the ISS and reports times, peak and direction', async () => { const catalog = composeCatalog({ tools: coreTools, From ac62542641d47f06330f56398b2d0e5f75a9b041 Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:31 -0400 Subject: [PATCH 10/45] fix(satellites): decode Alpha-5 catalog numbers in the shared TLE module Catalog numbers above 99999 arrive in a TLE as Alpha-5: a letter A-Z, skipping I and O, stands for 10-33 in the ten-thousands place, so 100000 is A0000. satellite.js keeps the field as the string satrec.satnum, and the three catalog builders converted it with Number(), which gives NaN; the Set and Map used to deduplicate and store treat NaN as equal to NaN, so the first such satellite was stored under NaN and every later one was dropped as a duplicate (#751). The satellite tools read the same field with parseInt and reported no number, so such a satellite could not be found by it. The decoder lives in src/sources/tle.js, beside parseTleText, which the layer and the tools both import already: noradIdFromSatnum() for the three builders, and tleCatalogNumber() decodes through the same function. Every other field reads as it did before: Number() in the layer, parseInt or null in the tools. --- src/layers/satellites/catalog.js | 3 +- src/layers/satellites/ingestion.js | 3 +- src/layers/satellites/orbits.js | 4 +-- src/sources/tle.js | 44 ++++++++++++++++++++++++- src/tools/queries/space.test.mjs | 52 +++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..fd136c82d 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { twoline2satrec } from 'satellite.js'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { DENSE_REFRESH_FRAMES, DENSE_GROUP_PATH, @@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entry = entries[i]; const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); + const noradId = noradIdFromSatnum(satrec.satnum); if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..14e464892 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,5 +1,6 @@ import { twoline2satrec } from 'satellite.js'; import * as Cesium from 'cesium'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -108,7 +109,7 @@ export function createIngestion({ const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); + const noradId = noradIdFromSatnum(satrec.satnum); if (seen.has(noradId)) continue; seen.add(noradId); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 32bb57cf9..0e4461e85 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -8,7 +8,7 @@ import { twoline2satrec, } from 'satellite.js'; import { findNextSatellitePass } from '../../data/satellitePass.js'; -import { parseTleText } from '../../sources/tle.js'; +import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js'; import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; export function createOrbits({ state: layerState, services, parts, source }) { @@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { - noradId: Number(satrec.satnum), + noradId: noradIdFromSatnum(satrec.satnum), name: String(name || '').trim(), current, periodSec: orbitalPeriodSeconds(satrec), diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..f5fad48bd 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -21,8 +21,50 @@ export function parseTleText(text) { return result; } +/** + * The catalog number an Alpha-5 field stands for, or null for any other field. + * + * Since the catalog passed 99999 (July 2026) a TLE writes the numbers above it + * in Alpha-5: a letter A–Z, skipping I and O, stands for 10–33 in the + * ten-thousands place, so 100000 is "A0000" and 339999 is "Z9999". + */ +function alpha5CatalogNumber(field) { + const letter = field[0]; + if ( + letter >= 'A' && + letter <= 'Z' && + letter !== 'I' && + letter !== 'O' && + /^\d{4}$/.test(field.slice(1)) + ) { + let tens = letter.charCodeAt(0) - 65 + 10; + if (letter > 'I') tens -= 1; + if (letter > 'O') tens -= 1; + return tens * 10000 + Number(field.slice(1)); + } + return null; +} + +/** + * NORAD catalog number of a satrec as a number. + * + * satellite.js keeps the TLE's five-character field verbatim in + * `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite + * would share one NaN key. An Alpha-5 field decodes to its number; a + * five-digit field is unchanged; anything else stays NaN. + * @param {string} satnum + * @returns {number} + */ +export function noradIdFromSatnum(satnum) { + const field = String(satnum ?? '').trim(); + return alpha5CatalogNumber(field) ?? Number(field); +} + /** The NORAD catalog number from TLE line 1, or null. */ export function tleCatalogNumber(line1) { - const number = Number.parseInt(String(line1).slice(2, 7), 10); + const field = String(line1).slice(2, 7); + const alpha5 = alpha5CatalogNumber(field); + if (alpha5 !== null) return alpha5; + const number = Number.parseInt(field, 10); return Number.isInteger(number) ? number : null; } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index f6c2a8df8..5ff267ce1 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; -import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; +import { + noradIdFromSatnum, + parseTleText, + tleCatalogNumber, +} from '../../sources/tle.js'; // Element sets published for 1 January 2024. const TLE = `ISS (ZARYA) @@ -51,6 +56,51 @@ for (const missingLine of [1, 2]) { }); } +test('Alpha-5 catalog numbers decode, skipping I and O', () => { + for (const [field, id] of [ + ['A0000', 100000], + ['A0404', 100404], + ['H9999', 179999], + ['J0000', 180000], + ['N9999', 229999], + ['P0000', 230000], + ['T0449', 270449], + ['Z9999', 339999], + ]) { + assert.equal(noradIdFromSatnum(field), id, field); + assert.equal(tleCatalogNumber(`1 ${field}U`), id, field); + } +}); + +test('every other catalog field reads as it did before', () => { + assert.equal(noradIdFromSatnum('25544'), 25544); + assert.equal(noradIdFromSatnum('00404'), 404); + assert.equal(noradIdFromSatnum(''), 0); // as Number('') did + for (const field of ['I0000', 'O0000', 'a0404', 'A00']) { + assert.ok(Number.isNaN(noradIdFromSatnum(field)), field); + assert.equal(tleCatalogNumber(`1 ${field}U`), null, field); + } +}); + +test('a satellite numbered above 99999 keeps its number and is found by it', async () => { + // The ISS set with its catalog field in Alpha-5: A0000 is 100000. + const text = TLE.replaceAll('25544', 'A0000'); + const [iss] = parseTleText(text); + const { satnum } = twoline2satrec(iss.line1, iss.line2); + assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used + assert.equal(noradIdFromSatnum(satnum), 100000); + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(text), clock: CLOCK }, + }); + const result = await catalog.call('next_satellite_pass', { + location: { lat: 40.7, lon: -74 }, + satellite: '100000', + }); + assert.equal(result.data.satellite, 'ISS (ZARYA)'); + assert.equal(result.data.norad, 100000); +}); + test('the next pass defaults to the ISS and reports times, peak and direction', async () => { const catalog = composeCatalog({ tools: coreTools, From 6b70392f3fa8d0c984c8cbf693f2485263bc575d Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:32 -0400 Subject: [PATCH 11/45] docs: note Alpha-5 catalog numbers in the changelog and current state --- CHANGELOG.md | 8 ++++++++ docs/CURRENT-STATE.md | 1 + 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c885e2caa..213563788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -386,6 +386,14 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Read satellite catalog numbers above 99999. A TLE writes them in Alpha-5, + a letter for the first two digits (`A0000` is 100000), which the satellites + layer converted to `NaN`: every such satellite shared one catalog entry and + all but the first were dropped as duplicates. The satellite tools read the + same field as no number, so such a satellite could not be found by it. The + layer and the tools now decode the field in `src/sources/tle.js` + (Honorius Neogy, #767, fixes #751). + ## [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, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2cc281812..15a4ec999 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4139,6 +4139,7 @@ silently demoting every later lookup for the session. - **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. +- **Catalog numbers above 99999** arrive in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. `src/sources/tle.js` decodes the field: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. A five-digit field reads as before; a field that is neither stays `NaN` in the layer and `null` in the tools. - **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash `fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration From f60eeacb2d76d2b8254cdc55708c6ab077419266 Mon Sep 17 00:00:00 2001 From: djabhi31 Date: Fri, 16 Oct 2026 19:12:46 +0530 Subject: [PATCH 12/45] test(earthquakes): add unit test coverage for earthquake snapshot normalization - Pin GeoJSON earthquake snapshot normalization contract in src/layers/earthquakes/records.test.mjs - Cover complete-feed validation, M2.5 magnitude filtering, coordinate and depth bounds - Verify stable fallback IDs, duplicate ID rejection, and malformed structure handling - Register records.test.mjs in scripts/format-scope.json and apply Prettier formatting --- scripts/format-scope.json | 1 + src/layers/earthquakes/records.test.mjs | 261 ++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/layers/earthquakes/records.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 34e12d6c2..bbc7960a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -722,6 +722,7 @@ "src/voice/session.test.mjs", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/layers/earthquakes/records.test.mjs", "src/sources/sourceSlot.js", "src/data/feedState.js", "src/app/localGeojsonServices.js", diff --git a/src/layers/earthquakes/records.test.mjs b/src/layers/earthquakes/records.test.mjs new file mode 100644 index 000000000..cd7b6a874 --- /dev/null +++ b/src/layers/earthquakes/records.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeEarthquakeSnapshot } from './records.js'; + +test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => { + const geojson = { + type: 'FeatureCollection', + features: [ + { + id: 'us7000m123', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194, 37.7749, 10.5], + }, + properties: { + mag: 4.2, + place: 'San Francisco Bay Area, CA', + time: 1711929600000, + }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(Array.isArray(rows), true); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + stableId: 'us7000m123', + usgsId: 'us7000m123', + lon: -122.4194, + lat: 37.7749, + depthKm: 10.5, + mag: 4.2, + place: 'San Francisco Bay Area, CA', + time: 1711929600000, + }); +}); + +test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => { + const geojson = { + features: [ + { + id: null, + geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] }, + properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 }, + }, + { + id: '', + geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] }, + properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 2); + assert.equal(rows[0].stableId, 'event-1'); + assert.equal(rows[0].usgsId, null); + assert.equal(rows[1].stableId, 'event-2'); + assert.equal(rows[1].usgsId, ''); +}); + +test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => { + const geojson = { + features: [ + { + id: 'ev-sub', + geometry: { type: 'Point', coordinates: [10, 20, 5] }, + properties: { mag: 2.49, place: 'Minor tremor' }, + }, + { + id: 'ev-null-mag', + geometry: { type: 'Point', coordinates: [11, 21, 5] }, + properties: { mag: null, place: 'Uncalculated' }, + }, + { + id: 'ev-threshold', + geometry: { type: 'Point', coordinates: [12, 22, 5] }, + properties: { mag: 2.5, place: 'Threshold event' }, + }, + { + id: 'ev-strong', + geometry: { type: 'Point', coordinates: [13, 23, 5] }, + properties: { mag: 6.8, place: 'Major event' }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 2); + assert.equal(rows[0].stableId, 'ev-threshold'); + assert.equal(rows[0].mag, 2.5); + assert.equal(rows[1].stableId, 'ev-strong'); + assert.equal(rows[1].mag, 6.8); +}); + +test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => { + const geojson = { + features: [ + { + id: 'ev-sparse', + geometry: { coordinates: [-100, 40] }, // depth omitted + properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 1); + assert.equal(rows[0].depthKm, null); + assert.equal(rows[0].place, null); + assert.equal(rows[0].time, null); +}); + +test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => { + assert.equal(normalizeEarthquakeSnapshot(null), null); + assert.equal(normalizeEarthquakeSnapshot(undefined), null); + assert.equal(normalizeEarthquakeSnapshot({}), null); + assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null); + assert.equal(normalizeEarthquakeSnapshot({ features: null }), null); +}); + +test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => { + const baseFeature = { + id: 'test', + geometry: { type: 'Point', coordinates: [0, 0, 0] }, + properties: { mag: 3.0 }, + }; + + // Non-Point geometry + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1], + ], + }, + }, + ], + }), + null, + ); + + // Missing or short coordinates + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { ...baseFeature, geometry: { type: 'Point', coordinates: [0] } }, + ], + }), + null, + ); + + // Longitude out of range [-180, 180] + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [180.1, 0, 0] }, + }, + ], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [-180.1, 0, 0] }, + }, + ], + }), + null, + ); + + // Latitude out of range [-90, 90] + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, 90.1, 0] }, + }, + ], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, -90.1, 0] }, + }, + ], + }), + null, + ); + + // Non-finite depth + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, 0, NaN] }, + }, + ], + }), + null, + ); + + // Magnitude > 10 + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: { mag: 10.1 } }], + }), + null, + ); + + // Non-object or array properties + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: 'invalid' }], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: [] }], + }), + null, + ); +}); + +test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => { + const geojson = { + features: [ + { + id: 'duplicate-id', + geometry: { type: 'Point', coordinates: [0, 0, 0] }, + properties: { mag: 3.0 }, + }, + { + id: 'duplicate-id', + geometry: { type: 'Point', coordinates: [1, 1, 1] }, + properties: { mag: 4.0 }, + }, + ], + }; + + assert.equal(normalizeEarthquakeSnapshot(geojson), null); +}); From 52863a9f422c4120b6533339b770b8ca7995121c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:10 -0700 Subject: [PATCH 13/45] fix(tools): count AIS ship type 0 as unknown in vessel breakdowns AIS type 0 means "not available" (ITU-R M.1371), but the source keeps it as '0', so group_by: 'type' listed "0" as a type and understated unknown_count. vessels_in_area rows now report it as null, and a regression fixture mixes type 0 with Cargo and Tanker. Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.js | 8 ++++++- src/tools/queries/maritime.test.mjs | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index a918c1f52..b5bdc4afe 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -32,6 +32,12 @@ const ALL_VESSELS = 1_000_000; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const text = (value) => (typeof value === 'string' && value ? value : null); +// AIS ship type 0 is "not available" (ITU-R M.1371); the source keeps it as +// '0', so it is reported as unknown rather than counted as a type. +const vesselType = (value) => { + const type = text(value); + return type === '0' ? null : type; +}; function vesselRow(record, from) { const point = { lat: record.latitude, lon: record.longitude }; @@ -39,7 +45,7 @@ function vesselRow(record, from) { mmsi: record.id, name: text(record.name), imo: text(record.imo), - type: text(record.type), + type: vesselType(record.type), destination: text(record.destination), lat: round(record.latitude, 5), lon: round(record.longitude, 5), diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 662e5bdf2..c5a5ab21f 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -225,6 +225,41 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('radius_km'), false); }); +test('AIS type 0 (not available) is counted as unknown, not as a type', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { type: '0' }), + vessel('366000002', 37.79, -122.41, { type: '0' }), + vessel('366000003', 37.77, -122.39, { type: 'Cargo' }), + vessel('366000004', 37.76, -122.38, { type: 'Tanker' }), + ], + }), + }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.deepEqual(result.data.breakdown, { + field: 'type', + groups: [ + { value: 'Cargo', count: 1 }, + { value: 'Tanker', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); + assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.doesNotMatch(result.summary, /0 \(/); + assert.equal( + result.data.rows.find((row) => row.mmsi === '366000001').type, + null, + ); +}); + test('vessels in an area can be counted by destination with a speed summary', async () => { const catalog = composeCatalog({ tools: coreTools, From 60444cca53af98c21713d2fe4825efc3d20b69c2 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:24 -0700 Subject: [PATCH 14/45] style: format the AIS type 0 regression test Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index c5a5ab21f..f1d09bccb 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -252,7 +252,10 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async () other_count: 0, unknown_count: 2, }); - assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.match( + result.summary, + /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./, + ); assert.doesNotMatch(result.summary, /0 \(/); assert.equal( result.data.rows.find((row) => row.mmsi === '366000001').type, From 0d8caaafd8c245dda6524ce4aadfb62bc1308203 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 15:24:08 -0400 Subject: [PATCH 15/45] fix(launches): keep null pad and landing coordinates unknown instead of 0 Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ++++ src/data/rocketLaunches.test.mjs | 25 +++++++++++++++++++++++++ src/layers/launches/model.js | 17 ++++++++--------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee00d839..38c4f31ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Launch Library records with a null or blank pad latitude/longitude, landing + position, downrange distance or stage flight number now keep those values + unknown instead of reading them as 0, so a launch is no longer placed at + 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index a1a3fab44..ce2ad6c2a 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,6 +676,31 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); +test('null or blank pad and landing coordinates stay unknown instead of becoming 0', () => { + const base = { net: '2026-07-20T10:00:00Z' }; + for (const blank of [null, '', ' ', false]) { + assert.deepEqual( + normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW), + [], + ); + } + // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. + const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback', + pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW); + assert.equal(fallback.lat, 28.6); + assert.equal(fallback.lon, -80.6); + // An undisclosed landing keeps position, distance and flight number unknown. + const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 }, + rocket: { launcher_stage: [{ launcher_flight_number: null, + landing: { attempt: true, downrange_distance: null, + landing_location: { latitude: null, longitude: null } } }] } }], NOW); + const [stage] = launch.recoveryStages; + assert.equal(stage.lat, null); + assert.equal(stage.lon, null); + assert.equal(stage.downrangeKm, null); + assert.equal(stage.flightNumber, null); +}); + test('normalizes detailed payload and stage recovery records', () => { const launches = normalizeRocketLaunches({ results: [{ id: 'recovery-details', diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 0a9dd67ea..5697e07e8 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown'); export function createModel({ state: layerState, services, parts, source }) { function finiteCoordinate(value) { + // Number(null), Number('') and Number(false) are 0: absent is not zero. + if (value == null || typeof value === 'boolean') return null; + if (typeof value === 'string' && value.trim() === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } @@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) { const pad = launch.pad || {}; const location = pad.location || {}; const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates) - .split(',') - .map(Number); - const lat = Number.isFinite(Number(pad.latitude)) - ? Number(pad.latitude) - : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) - ? Number(pad.longitude) - : coordinateLon; + const [coordinateLon, coordinateLat] = String(coordinates).split(','); + const lat = + finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat); + const lon = + finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon); const payloads = normalizePayloadFlights(launch); return { id: String( From 8f6671b6c271abc9464ac990259a03db6943695c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:22:39 -0700 Subject: [PATCH 16/45] Merge upstream main into feat/tools-area-breakdowns Move the changelog entry into changelog.d/1056-area-tool-breakdowns.md (upstream's one-file-per-PR rule, #787) and leave CHANGELOG.md as on main. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ----- changelog.d/1056-area-tool-breakdowns.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1056-area-tool-breakdowns.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dce6a93..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md new file mode 100644 index 000000000..75f713907 --- /dev/null +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -0,0 +1,6 @@ +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them; AIS ship type 0 ("not available") counts as unknown + (quanticsoul4772, #1056). From d3e11bfbcaa7656fdd7db5b607c152537b4cd4bd Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:19:12 -0400 Subject: [PATCH 17/45] test(launches): cover null top-level landing coordinates Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ---- changelog.d/1186-launch-unknown-coordinates.md | 4 ++++ src/data/rocketLaunches.test.mjs | 14 ++++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1186-launch-unknown-coordinates.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c4f31ef..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Launch Library records with a null or blank pad latitude/longitude, landing - position, downrange distance or stage flight number now keep those values - unknown instead of reading them as 0, so a launch is no longer placed at - 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md new file mode 100644 index 000000000..d03c2d902 --- /dev/null +++ b/changelog.d/1186-launch-unknown-coordinates.md @@ -0,0 +1,4 @@ +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index ce2ad6c2a..0d1eb8956 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,7 +676,7 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); -test('null or blank pad and landing coordinates stay unknown instead of becoming 0', () => { +test('null or blank pad coordinates stay unknown instead of becoming 0', () => { const base = { net: '2026-07-20T10:00:00Z' }; for (const blank of [null, '', ' ', false]) { assert.deepEqual( @@ -684,15 +684,21 @@ test('null or blank pad and landing coordinates stay unknown instead of becoming [], ); } - // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. +}); + +test('null pad coordinates use the location fallback instead of 0,0', () => { + const base = { net: '2026-07-20T10:00:00Z' }; const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback', pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW); assert.equal(fallback.lat, 28.6); assert.equal(fallback.lon, -80.6); - // An undisclosed landing keeps position, distance and flight number unknown. +}); + +test('an undisclosed landing keeps top-level position and stage numbers unknown', () => { + const base = { net: '2026-07-20T10:00:00Z' }; const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 }, rocket: { launcher_stage: [{ launcher_flight_number: null, - landing: { attempt: true, downrange_distance: null, + landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null, landing_location: { latitude: null, longitude: null } } }] } }], NOW); const [stage] = launch.recoveryStages; assert.equal(stage.lat, null); From 4690a5bea97f2969c77fb35219ceed9af7d02c48 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:20:07 -0400 Subject: [PATCH 18/45] docs(launches): describe unknown-coordinate and flight labels Signed-off-by: Rudy Celekli --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..105fdd37f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -791,6 +791,11 @@ and `./layers/launches`. Each owns its catalog, render state, tracking, interact and teardown. Source adapters retain the existing CelesTrak and Launch Library endpoints; standalone entries provide scene services and the satellite dependency. The mission factory separates paths, replay, camera, panel, overlays and placement. +Launch Library pad coordinates retain unknown values: missing fields use the +reported location coordinates when available, and launches without a known pad +position stay off the map. Genuine zero coordinates remain valid. Undisclosed +stage coordinates, downrange distance and flight numbers remain unknown; the +mission panel omits the flight-number label when no number was reported. Pending mission requests abort on disable/destruction and malformed launch snapshots preserve the last accepted display. Catalog groups, propagation cadence, tracking intent, replay timing, controls and source attribution remain unchanged. From c7ff33ac2e0e7f4ab16562e652a24d64e2bd2a3a Mon Sep 17 00:00:00 2001 From: Sunil Date: Sat, 17 Oct 2026 14:36:24 +0530 Subject: [PATCH 19/45] fix(server): admit reverse-proxied same-origin terrain requests Review follow-up on #975. The shared same-site gate refuses any forwarding header, which is right for routes that spend a key, but terrain is keyless and needed for rendering: behind the reverse proxy the README documents, every /api/terrain/heights chunk got 403 and fell back to the geoid. Terrain now uses admitSameSiteKeyless, which keeps the Sec-Fetch-Site and exact-Origin checks (comparing Origin against a single X-Forwarded-Proto scheme) but does not refuse forwarding headers. The shared admitSameSite gate is unchanged for every other route. The changelog entry moves to changelog.d/975-terrain-cross-site-gate.md. Co-authored-by: daikaginza --- SECURITY.md | 2 +- changelog.d/975-terrain-cross-site-gate.md | 8 ++ server/providers/common/same-site.js | 45 ++++++++++ server/providers/terrain.js | 9 +- src/proxyErrorResponses.test.mjs | 2 +- src/terrainHeightsBounds.test.mjs | 96 +++++++++++++++++++++- 6 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 changelog.d/975-terrain-cross-site-gate.md diff --git a/SECURITY.md b/SECURITY.md index e53883548..b694d8dcd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `