From feba5af0b98026c924decbc21737622125810bb5 Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:05:56 -0500 Subject: [PATCH 01/13] Add OpenSeaMap / OpenSnowMap overlay layers (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independently toggleable open-data raster overlays — OpenSeaMap sea marks (buoys, lighthouses, ports) and OpenSnowMap ski pistes — drawn as Cesium.ImageryLayers directly on the Cesium globe surface. Same "small array of descriptors" shape as MAP_STACKS in mapStackController.js. Visible on the osm/bing-aerial/bing-labels map stacks and invisible (not broken — the layer's stats say so in plain text) on the default photoreal stack, since Google Photorealistic 3D Tiles render with the globe hidden. OpenRailwayMap and OpenInfraMap, also requested in #10, are deliberately NOT included in this pass: OpenRailwayMap's public tile server enforces a Referer allowlist scoped to its own site (confirmed by direct request — every other Referer, including none, 403s), and OpenInfraMap serves MapLibre GL vector tiles, not raster XYZ — that would need a genuine vector-tile rendering pipeline, not a Cesium.ImageryLayer drop-in. Worth its own dedicated pass. Co-Authored-By: Claude Sonnet 5 --- DATA_SOURCES.md | 3 + src/data/dataCredits.js | 12 +++ src/data/layerState.js | 9 ++ src/data/layerState.test.mjs | 4 +- src/data/osmOverlays.js | 191 ++++++++++++++++++++++++++++++++++ src/data/osmOverlays.test.mjs | 41 ++++++++ src/standalone/data.js | 2 + src/voice/gevActions.js | 10 ++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/data/osmOverlays.js create mode 100644 src/data/osmOverlays.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index faa351dca..7f5cdbdad 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -37,10 +37,13 @@ How to read this: | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | +| **OpenSeaMap** (`tiles.openseamap.org`) | Sea marks raster overlay (buoys, lighthouses, ports) — one of two Open Map Overlays (#10) | Map data ODbL 1.0 via OpenStreetMap | "© OpenSeaMap contributors" | +| **OpenSnowMap** (`opensnowmap.org`) | Ski piste raster overlay — one of two Open Map Overlays (#10) | Map data ODbL 1.0 (OpenStreetMap) + SRTM; map style CC BY-SA | "Map data: © OpenStreetMap contributors, SRTM \| Map style: © OpenSnowMap.org (CC-BY-SA)" | ### Notes on the live sources - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). +- **Open Map Overlays (#10).** Two independently toggleable keyless raster overlays — OpenSeaMap sea marks and OpenSnowMap ski pistes — drawn as `Cesium.ImageryLayer`s directly on the Cesium globe surface. Like every globe-surface source, they render on the `osm`/`bing-aerial`/`bing-labels` map stacks and are invisible on the default `photoreal` stack (Google Photorealistic 3D Tiles render with the globe hidden; see `mapStackController.js`) — the layer's stats surface that as plain text instead of looking broken. OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's public tile server enforces a Referer allowlist restricted to its own site (confirmed by direct request — any other Referer, including none, 403s), and OpenInfraMap serves MapLibre vector tiles rather than raster XYZ tiles, which would need a genuine vector-tile rendering pipeline, not an imagery-layer drop-in. - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index b35da9e00..5cd3d1b66 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -66,6 +66,18 @@ export const DATA_CREDITS = [ key: 'usgs', html: 'Earthquakes: Data courtesy of the U.S. Geological Survey', }, + { + key: 'openseamap', + html: + 'Sea marks overlay: ' + + '© OpenSeaMap contributors', + }, + { + key: 'opensnowmap', + html: + 'Ski pistes overlay: Map data © OpenStreetMap contributors, SRTM | Map style © ' + + 'OpenSnowMap.org (CC BY-SA)', + }, { key: 'overpass', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index eb68c14e7..c209634f6 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -223,6 +223,14 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('showProjection', 'p', true), booleanOption('autoHop', 'a', false), ]), + // Two independently toggleable open-data raster overlays (#10) sharing one + // owner — same "one master row, several booleans" shape as `cctv`/`radio` + // above, not a new mechanism. Both default ON: switching the row on with + // nothing yet chosen would show nothing and read as broken. + osmOverlays: Object.freeze([ + booleanOption('seamap', 's', true), + booleanOption('snowmap', 'n', true), + ]), radio: Object.freeze([ Object.freeze({ key: 'filter', @@ -286,6 +294,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights' }), Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only' }), Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only' }), + Object.freeze({ id: 'osm-overlays', token: 'j', disposition: 'enabled+options', optionOwner: 'osmOverlays' }), Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio' }), Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only' }), Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e75e..2cb4ed2bf 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -155,8 +155,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 16); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16); + assert.equal(REGISTERED_LAYER_IDS.length, 17); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js new file mode 100644 index 000000000..7183813fe --- /dev/null +++ b/src/data/osmOverlays.js @@ -0,0 +1,191 @@ +import * as Cesium from 'cesium'; + +/** + * @file Open-data raster overlays drawn on the Cesium GLOBE surface — + * OpenSeaMap sea marks and OpenSnowMap ski pistes (#10). Two independently + * toggleable overlays sharing one config-driven module, same "small array of + * descriptors" shape as `MAP_STACKS` in mapStackController.js. + * + * OpenRailwayMap and OpenInfraMap, also named in #10, are deliberately NOT + * included in this pass: + * - OpenRailwayMap's public tile server (tiles.openrailwaymap.org) enforces + * a Referer allowlist scoped to openrailwaymap.org itself — verified by + * direct request: a Referer of `https://www.openrailwaymap.org/` returns + * the tile, every other Referer (including none) returns 403, regardless + * of User-Agent. A browser tab on this app sends ITS OWN origin as + * Referer, so the tiles would 403 in practice, and embedding them here + * would run against their stated tile-usage policy either way + * (CONTRIBUTING.md: "Don't add scraping of sources whose terms forbid it"). + * - OpenInfraMap serves MapLibre GL vector tiles (MVT) from its own + * infrastructure, not simple raster XYZ tiles — adding it means a genuine + * vector-tile rendering pipeline, not a `Cesium.ImageryLayer` drop-in. + * Worth its own dedicated pass; out of scope here. + * + * Like every non-photoreal-stack raster source, these overlays draw on the + * Cesium globe (`viewer.scene.globe`), not on the Google Photorealistic 3D + * Tiles mesh (see `mapStackController.js`: the photoreal stack renders with + * `globe.show = false`, and a hidden globe requests and draws no imagery). + * They're visible on the `osm`/`bing-aerial`/`bing-labels` map stacks and + * invisible — not broken, just nothing to draw on — on the default + * `photoreal` stack. `getStats().error` surfaces that in plain language + * whenever an enabled overlay is currently showing nothing for this reason. + * @module data/osmOverlays + */ + +export const OSM_OVERLAYS_LAYER_ID = 'osm-overlays'; + +/** @type {Array<{key:string,label:string,icon:string,urlTemplate:string,credit:string,maximumLevel:number}>} */ +export const OSM_OVERLAYS = Object.freeze([ + Object.freeze({ + key: 'seamap', + label: 'Sea Marks', + icon: '⚓', + // Confirmed live 2026-08-25: https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png + urlTemplate: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', + credit: '© OpenSeaMap contributors', + maximumLevel: 18, + }), + Object.freeze({ + key: 'snowmap', + label: 'Ski Pistes', + icon: '⛷', + // Confirmed live 2026-08-25: https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png + urlTemplate: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png', + credit: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenSnowMap.org (CC-BY-SA)', + maximumLevel: 18, + }), +]); + +const DEFAULT_PARAMS = Object.freeze( + Object.fromEntries(OSM_OVERLAYS.map((overlay) => [overlay.key, true])), +); + +/** Reason an enabled, chosen overlay is currently showing nothing, or null. */ +export function osmOverlaysHiddenReason(enabled, params, globeShown) { + if (!enabled) return null; + const anyChosen = OSM_OVERLAYS.some((overlay) => Boolean(params?.[overlay.key])); + if (!anyChosen) return null; + if (globeShown) return null; + return 'Overlays draw on the globe surface — switch the map stack to OSM or Bing (not Google 3D) to see them'; +} + +/** + * Sanitize a candidate params object against the two known overlay keys. + * Unknown keys are ignored; a non-boolean value for a known key leaves that + * key's current value untouched rather than coercing it (mirrors the + * `undefined` = "not requested" convention `satellites.js`/`radio.js` use). + * @param {object} current — the module's live `{seamap, snowmap}` state + * @param {object} candidate + * @returns {{next: object, changed: boolean}} + */ +export function applyOsmOverlayParams(current, candidate = {}) { + const next = { ...current }; + let changed = false; + for (const overlay of OSM_OVERLAYS) { + if (!Object.hasOwn(candidate, overlay.key)) continue; + const value = candidate[overlay.key]; + if (typeof value !== 'boolean') continue; + if (next[overlay.key] !== value) changed = true; + next[overlay.key] = value; + } + return { next, changed }; +} + +const state = { + viewer: null, + enabled: false, + params: { ...DEFAULT_PARAMS }, + imageryLayers: new Map(), +}; + +function syncImageryLayerVisibility() { + for (const overlay of OSM_OVERLAYS) { + const layer = state.imageryLayers.get(overlay.key); + if (layer) layer.show = state.enabled && Boolean(state.params[overlay.key]); + } +} + +const osmOverlaysLayer = { + id: OSM_OVERLAYS_LAYER_ID, + name: 'Open Map Overlays', + icon: '🗺️', + source: 'OpenSeaMap / OpenSnowMap', + updateInterval: 0, + init(viewer) { + state.viewer = viewer; + for (const overlay of OSM_OVERLAYS) { + const provider = new Cesium.UrlTemplateImageryProvider({ + url: overlay.urlTemplate, + credit: overlay.credit, + maximumLevel: overlay.maximumLevel, + }); + const layer = new Cesium.ImageryLayer(provider); + layer.show = false; + // Appended (no index) so it draws on top of the base map-stack imagery + // layer mapStackController.js keeps at index 0. + viewer.imageryLayers.add(layer); + state.imageryLayers.set(overlay.key, layer); + } + }, + enable() { + state.enabled = true; + syncImageryLayerVisibility(); + }, + disable() { + state.enabled = false; + syncImageryLayerVisibility(); + }, + update() { + return true; + }, + setParams(params = {}) { + const { next, changed } = applyOsmOverlayParams(state.params, params); + if (changed) { + state.params = next; + syncImageryLayerVisibility(); + } + return true; + }, + getParams() { + return { ...state.params }; + }, + getRowControls() { + if (!state.enabled) return { chips: [], legend: [] }; + const globeShown = state.viewer?.scene?.globe?.show !== false; + return { + chips: OSM_OVERLAYS.map((overlay) => { + const active = Boolean(state.params[overlay.key]); + return { + id: overlay.key, + label: `${overlay.icon} ${overlay.label}`, + active, + title: globeShown + ? overlay.credit + : `${overlay.credit} — needs the OSM or Bing map stack to render, not Google 3D`, + params: { [overlay.key]: !active }, + }; + }), + legend: [], + }; + }, + destroy(viewer) { + this.disable(); + for (const layer of state.imageryLayers.values()) { + if (viewer) viewer.imageryLayers.remove(layer, true); + } + state.imageryLayers.clear(); + state.viewer = null; + state.params = { ...DEFAULT_PARAMS }; + }, + getStats() { + const globeShown = state.viewer?.scene?.globe?.show !== false; + return { + count: state.enabled + ? OSM_OVERLAYS.filter((overlay) => state.params[overlay.key]).length + : 0, + error: osmOverlaysHiddenReason(state.enabled, state.params, globeShown), + }; + }, +}; + +export default osmOverlaysLayer; diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs new file mode 100644 index 000000000..d4dcbe691 --- /dev/null +++ b/src/data/osmOverlays.test.mjs @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + OSM_OVERLAYS, + applyOsmOverlayParams, + osmOverlaysHiddenReason, +} from './osmOverlays.js'; + +test('OSM_OVERLAYS: exactly the two shipped overlays, each with a tile template and credit', () => { + assert.deepEqual(OSM_OVERLAYS.map((o) => o.key), ['seamap', 'snowmap']); + for (const overlay of OSM_OVERLAYS) { + assert.match(overlay.urlTemplate, /\{z\}.*\{x\}.*\{y\}/); + assert.ok(overlay.credit); + } +}); + +test('osmOverlaysHiddenReason: null when disabled, nothing chosen, or the globe is shown', () => { + assert.equal(osmOverlaysHiddenReason(false, { seamap: true, snowmap: true }, false), null); + assert.equal(osmOverlaysHiddenReason(true, { seamap: false, snowmap: false }, false), null); + assert.equal(osmOverlaysHiddenReason(true, { seamap: true, snowmap: false }, true), null); +}); + +test('osmOverlaysHiddenReason: explains itself when enabled + chosen but the photoreal globe is hidden', () => { + const reason = osmOverlaysHiddenReason(true, { seamap: true, snowmap: false }, false); + assert.ok(reason && /globe/i.test(reason)); +}); + +test('applyOsmOverlayParams: flips only known keys with boolean values, reports whether anything changed', () => { + const current = { seamap: true, snowmap: true }; + const a = applyOsmOverlayParams(current, { seamap: false }); + assert.deepEqual(a.next, { seamap: false, snowmap: true }); + assert.equal(a.changed, true); + + const b = applyOsmOverlayParams(current, { seamap: true }); + assert.deepEqual(b.next, current); + assert.equal(b.changed, false); + + const c = applyOsmOverlayParams(current, { unknownKey: false, snowmap: 'nope' }); + assert.deepEqual(c.next, current); + assert.equal(c.changed, false); +}); diff --git a/src/standalone/data.js b/src/standalone/data.js index 1c3e20366..a7359c584 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -10,6 +10,7 @@ import radioLayer from '../data/radio.js'; import bikeshareLayer from '../data/bikeshare.js'; import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; import militaryInstallationsLayer from '../data/militaryInstallations.js'; +import osmOverlaysLayer from '../data/osmOverlays.js'; import militaryAwarenessLayer from '../data/militaryAwareness.js'; import localDataLayers from '../data/localLayers.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; @@ -44,6 +45,7 @@ export function createStandaloneData({ dataManager.register(bikeshareLayer); dataManager.register(aisLiveVesselsLayer); dataManager.register(militaryInstallationsLayer); + dataManager.register(osmOverlaysLayer); dataManager.register(militaryAwarenessLayer); militaryAwarenessLayer.attachDataManager(dataManager); for (const layer of localDataLayers) { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..3ab9d149e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -176,6 +176,16 @@ const LAYER_ALIASES = new Map([ ['firms', 'local-firms'], ['fires', 'local-firms'], ['active fires', 'local-firms'], + ['map overlays', 'osm-overlays'], + ['open map overlays', 'osm-overlays'], + ['sea marks', 'osm-overlays'], + ['seamarks', 'osm-overlays'], + ['nautical charts', 'osm-overlays'], + ['openseamap', 'osm-overlays'], + ['ski pistes', 'osm-overlays'], + ['ski slopes', 'osm-overlays'], + ['snow map', 'osm-overlays'], + ['opensnowmap', 'osm-overlays'], ]); const CITY_ALIASES = new Map([ From 55f979c128c0e979c430ad5f7d82c358e30c84b4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 21 Oct 2026 14:59:53 +0100 Subject: [PATCH 02/13] fix(overpass): keyless gate, loopback admission headroom and per-phase arrival budgets Address gekh's review of 69a87fe2: - P1: /api/overpass now uses admitSameSiteKeyless, like /api/terrain/heights, so an instance behind the README's reverse proxy keeps its Overpass-backed layers; cross-site shapes are still refused and forwarding headers no longer launder them. SECURITY.md, the gate header and CURRENT-STATE say so. - P2: admission keys IPv6 peers by /64 and folds IPv4-mapped peers (clientNetworkKey), and loopback has its own 300/min window outside the 900/min global, so a few LAN peers cannot lock the owner's browser out. - P2 (track): the settle and reveal frames each get their own 500 ms budget, measured from moveEnd and from the settle frame respectively, so a ~300 ms SwiftShader frame no longer trips the reveal deadline while both stay inside the 1 s idle refresh. - Smaller: drop the dead settings-block check and stale probe comment in query.js, clamp whitespace-tolerant [ timeout : N ], and add tests for the global window, the /64 keying, loopback headroom, proxied admission and cache hits spending admission but not upstream budget. Co-Authored-By: Claude Fable 5.1 --- SECURITY.md | 2 +- .../1264-overpass-request-admission.md | 4 +- docs/CURRENT-STATE.md | 11 +- scripts/format-scope.json | 1 + scripts/track-regression.mjs | 8 +- server/providers/common/rate-limit.js | 44 ++++++ .../providers/common/rateLimitKeys.test.mjs | 37 +++++ server/providers/common/same-site.js | 2 +- server/providers/overpass.js | 48 ++++-- server/providers/overpass/query.js | 16 +- .../overpass/requestAdmission.test.mjs | 145 +++++++++++++++++- src/localRequestGate.mjs | 17 +- src/tooling/trackArrivalObservation.mjs | 32 +++- src/tooling/trackArrivalObservation.test.mjs | 39 ++++- 14 files changed, 357 insertions(+), 49 deletions(-) create mode 100644 server/providers/common/rateLimitKeys.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 30203ed94..cfbffe3cb 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. 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. +- **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 map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all non-loopback peers before it reads a body; loopback has its own per-peer window, so LAN peers cannot lock the owner out. - **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 `