Compare commits

..
Author SHA1 Message Date
Sameh Khamis 9ab54675ba Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	scripts/package-boundaries.json
2026-11-02 17:01:02 -08:00
Sameh Khamis 09d833ed96 Add the changelog entry for the world clock and timeline 2026-11-02 13:17:06 -08:00
Sameh Khamis 32e08f5043 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-02 13:14:32 -08:00
Sameh Khamis 48093108b1 Make the world timeline a scrubber under the dock
The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
2026-11-01 23:37:12 -08:00
Sameh Khamis 9faec3028e Show the world time on the HUD clock
The HUD's UTC and collection readouts follow the world clock with the sky,
so away from live they read the time the globe is drawn at.
2026-11-01 23:17:53 -08:00
Sameh Khamis 3534398771 Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from
the start; live feeds such as flights, military, vessels and transit stay
live. Launches register the span of the launches loaded, so turning them on
brings up the timeline. Each timed layer registers its coverage once and
reports none while off, telling the clock after each step has settled. The
launch feed keeps its own window, and an orbit ring is drawn again once the
world time has moved far from when it was drawn.
2026-11-01 22:53:51 -08:00
Sameh Khamis 690a6637e2 Show the timeline only while a layer with time to show is on
The bar now needs more than the world clock being away from live: a layer
that moves with the clock must be on with time to show. In Core that is
weather radar, satellite or lightning with frames; a host adds layers by
following them. Turning the last one off hides the bar; turning it back
on brings it back.

The world clock reports this as `supported` in its state. Weather products
register their frames as coverage one by one, suspended or not, so the way
back to live stays on screen with reduced motion. Weather refreshes and
satellite catalog changes tell the clock their coverage may have changed.
A coverage that throws counts as none, and the clock reads its state once
per notification round.
2026-11-01 22:36:45 -08:00
Sameh Khamis 434fc9602d Show a basic timeline while the world time is away from live
A bar above the command dock, like a video player's: play and pause on
the left, the world time's date and time in UTC beside it, and Live to
return. It stays hidden while live, so Core looks as before until weather
history is scrubbed or replayed, a host moves the clock, or a link made
away from live is opened. Adds the pause and play_arrow glyphs and the
strings in every catalog.
2026-11-01 21:55:33 -08:00
Sameh Khamis 32ec6ed436 Share links carry the world time when made away from live
Field 28, worldTime, holds the world time of a link made away from live:
zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the
link's creation (a forecast moment). Opening such a link pauses the world
clock at that time; a link without it leaves the clock alone, so a page
opens live and Back or Forward does not stop a replay. Times no JS Date
can hold are dropped.
2026-11-01 21:50:09 -08:00
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
2026-11-01 21:47:32 -08:00
527 changed files with 4400 additions and 67786 deletions

No files matched your search

+2 -24
View File
@@ -61,15 +61,8 @@ CESIUM_ION_TOKEN=
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
# Realtime client secret. Do not prefix with VITE_.
OPENAI_API_KEY=
# Typed chat uses Chat Completions, independently of the voice model. It can
# only bill OPENAI_API_KEY, so GEV_PREFER_CODEX_OAUTH below turns it off.
OPENAI_CHAT_MODEL=gpt-4.1-mini
# Typed chat requests (/api/openai/chat) per IP per minute, counted apart from
# voice. Unset, it takes GEV_RATELIMIT_OPENAI_PER_MIN's cap; 0 disables it.
# Not a billing cap.
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=30
# Optional: forbid API-key voice sessions and OpenAI typed chat. Select ChatGPT
# OAuth in Provider Settings; missing or expired Codex sign-in then fails closed.
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
# Settings; missing or expired Codex sign-in then fails closed.
# GEV_PREFER_CODEX_OAUTH=true
OPENAI_REALTIME_MODEL=gpt-realtime-2
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
@@ -263,9 +256,6 @@ AISSTREAM_API_KEY=
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
# CCTV_HOUSTON_ENABLED= # off by default; set 1 to load Houston TranStar cameras only if you hold TxDOT's written permission (TranStar's Content Usage terms prohibit copying, distributing or framing its images without it; see DATA_SOURCES.md)
# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first)
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
@@ -273,7 +263,6 @@ AISSTREAM_API_KEY=
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
OVERPASS_UPSTREAMS=
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
@@ -306,14 +295,3 @@ OVERPASS_UPSTREAMS=
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
# GEV_EMBED_FRAME_ANCESTORS=
# Gemini typed chat uses generateContent; voice continues to use Gemini Live.
GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
# Typed chat requests (/api/gemini/chat) per IP per minute. Unset, it takes
# GEV_RATELIMIT_GEMINI_PER_MIN's cap (default 30); 0 disables it.
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=30
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
# Each reserves up to 40 stations within the existing 750-station directory.
# Restart after changing. Leave blank for the existing global selection.
# RADIO_PRIORITY_COUNTRIES=IN,PL
+8 -27
View File
@@ -40,9 +40,6 @@ How to read this:
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
@@ -72,7 +69,6 @@ How to read this:
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. **Off by default**; enable only if you hold TxDOT's written permission | "Houston TranStar" (courtesy) |
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
@@ -100,11 +96,8 @@ How to read this:
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **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 layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
### Local receiver input (not fetched from a service)
@@ -125,7 +118,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
- **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)).
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
- **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.
<a id="gnss-integrity"></a>
@@ -185,25 +177,17 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover.
- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals.
- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`.
- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
- **Voice areas and place search.** Voice areas (`resolve_area`) use the same outline sources as voice outlines. Voice place search (`osm_query`, `/api/osm/features`) runs only on an operator-configured Overpass: a fixed list of kinds, a bounded box and result count, one query at a time, answers cached for a day. It never uses Nominatim.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
@@ -230,7 +214,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
### ⚠️ TeleGeography is bundled but NonCommercial
@@ -258,16 +242,13 @@ fixtures retain their provenance in `src/data/fixtures/README.md`.
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC
records from v2.29.0) of the
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County,
Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in
[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the
module. Nothing is fetched at runtime. The MIC records keep their specification fields; their
titles are shortened to model codes and colour aliases are added. The records describe camera
products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
fetched at runtime. The records describe camera products (optics, resolution, night vision)
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
### Bundled OpenStreetMap data
+14 -22
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Local setup needs no account or API keys.** The app opens with
**Start without an account or API keys.** Every path opens the same app with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -212,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
</details>
@@ -236,11 +236,7 @@ the container.
### Path 4 — GitHub Codespaces (cloud, no local install)
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
The codespace runs the dev server in a cloud workspace, so Node and the dependencies
Runs the same dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
@@ -269,13 +265,14 @@ ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings is unavailable on the forwarded URL. Never commit your workspace
`.env`. [SECURITY.md](SECURITY.md) has the threat model.
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
### Then power it up — in the app, not in a file
For a local setup, keys are upgrades, not prerequisites. When you want one,
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
chip in the bottom-right corner: Provider Settings lists every supported key,
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
restarts itself with the new capability on. Once everything is configured the
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
@@ -362,11 +359,9 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
## 🎙️ Talk to It
The standalone app offers **OpenAI** and **Gemini** under **AI Provider** in Settings, one choice shared by voice and chat. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The AI Provider setting does not change the OpenAI-backed AI HUD summary.
Next to the mic, **CHAT** opens a typed chat with silent replies. It uses the selected provider and the same map actions, and never asks for the microphone. OpenAI chat needs an API key (`OPENAI_CHAT_MODEL`), so it is off while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth; Gemini chat uses a separate text model (`GEMINI_CHAT_MODEL`). Chat and voice panels share the left panels' glass surface and sit above the dock or dock to the right.
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
@@ -426,7 +421,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
## 🛰️ What's on the Globe
Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -440,26 +435,23 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions and OSM Places sit in the separate **Utilities** panel with Draw and Import.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
**The basemap ladder — what each tier buys you:**
+2 -4
View File
@@ -74,19 +74,17 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
- **Response-size caps and timeouts** on proxied responses.
- **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 `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator 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 and voice need them to work 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 remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. 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. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
- **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, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
- **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After the provider refuses the key or account (a `401`, a `403`, Gemini's `400` `API_KEY_INVALID` or OpenAI's `429` `insufficient_quota`), a route stops sending that key upstream for a minute, or until the key changes.
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
## Network exposure — the operator threat model
@@ -95,7 +93,7 @@ The dev server is a **key broker**: every server-side key above is spendable by
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Typed chat keeps its own per-IP window, set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`; left unset, each takes its provider's resolved cap (`GEV_RATELIMIT_OPENAI_PER_MIN`, or `GEV_RATELIMIT_GEMINI_PER_MIN`, default `30`), so lowering a provider throttle lowers chat too, while an explicit chat value, including `0`, wins. Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **Docker trusts one address, and only because the port is on loopback.**
Docker delivers the host's own browser to the container from the container
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
@@ -1,8 +0,0 @@
- Add a Prohibited & Restricted Airspace layer for FAA prohibited (P) and
restricted (R) special-use airspace. The keyless `/api/drone-airspace` proxy
pages the public SUA FeatureServer, keeps a one-hour snapshot, and serves
stale data when a refresh fails. Zones draw as ground polygons (red
prohibited, orange restricted); selection shows name, altitude range, times
of use and agency, and the row legend states the layer is informational only
and does not include the UAS Facility Map or UAS flight restrictions. Off by
default; share-link token `8` (mvanhorn, #1086).
-18
View File
@@ -1,18 +0,0 @@
- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera
list is one keyless JSON request to
`traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are
stills synthesized from each numeric camera id on `www.houstontranstar.org`,
so a catalog field cannot steer the proxy off-host. Cameras without a
snapshot id, and cameras outside Greater Houston, are dropped. TranStar's
`dir` field (the side of the freeway the camera sits on) seeds a
low-confidence heading along the nearest lanes' travel direction. TranStar's
Content Usage terms (traffic.houstontranstar.org/disclaimer.aspx) prohibit
copying, distribution and external framing of its images without TxDOT's
written permission, so the pack is off by default; operators should set
`CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
relayed on demand with `no-store` and not archived. Once enabled, it loads
the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and
Galveston; `CCTV_HOUSTON_MAX_SOURCES` changes that cap.
Snapshots update about every 3 minutes. The catalog-wide ceiling is
unchanged; extra cameras share it round-robin with the other packs
(mvanhorn, #1125).
-3
View File
@@ -1,3 +0,0 @@
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
overlay layers under Infrastructure. They drape on the globe stacks and on
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
@@ -1,6 +0,0 @@
- Add optional keyless Radio priority-country coverage with up to 40 stations
per configured country inside the existing 750-station cap; the UK alias uses
the canonical GB code. Country query
failures do not block a healthy global catalog. Bound mirror attempts to three
and retry failed or partial refreshes after 60 seconds on all installations,
including those without priority countries configured (m3ayush, #1206).
@@ -1,13 +0,0 @@
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
now uses the keyless same-site gate, admits forwarding headers and still
refuses cross-site requests. Direct loopback requests keep their own
admission window; proxied requests, including those a local reverse proxy
relays over loopback, are admitted as remote traffic keyed by their socket,
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
exhaust the owner's admission. The upstream budget stays shared, so an owner
cache miss can still be rate-limited (asturksever, #1308).
- Give the tracking arrival check a wall-clock budget per observed frame and
retry only an inconclusive timeout: a timeout keeps every angle it
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
frames no longer fail the check and a real violation is never retried into a
pass (asturksever, #1308).
-3
View File
@@ -1,3 +0,0 @@
- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera
register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour
wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319).
-12
View File
@@ -1,12 +0,0 @@
- Color every Active Fires detection by fire radiative power on a six-stop
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ /
High confidence switch and an "N of M shown" counter to the layer row. Far
zoom shows a Snap Map–style heatmap in the same colors instead of squares,
one distinct spot per fire, empty where nothing burns, and cross-fades with
the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before
rendering, so markers, cards, the heatmap, context and the top-fires query
agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires`
takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All
(gekh, #1426).
@@ -1,5 +0,0 @@
- Military aircraft that leave the adsb.lol feed fade and are removed again.
The feed's routine position-less rows no longer mark every snapshot partial,
which kept vanished aircraft at full opacity for up to five minutes; those
rows keep only their own aircraft, and a likely-landed one still leaves on
the landed limit (Aditya, @Rohilalala, #1435).
@@ -1,3 +0,0 @@
- A terrain request the proxy can only partly answer (a 429 pause, or a point
the upstream returns without a height) now keeps the real heights it has
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
@@ -1,4 +0,0 @@
- Ground-sample Data Centers and Dams markers only when the camera sees the
ground under them. Unseen ground read through the Earth and retried every
2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s
(quanticsoul4772, #1464).
@@ -1,5 +0,0 @@
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
before Play is pressed; while a stream plays, it follows the new station.
Play reads Pause while a stream connects, and a tuner release onto a station
the directory removed or changed no longer leaves a Resume for another
station (gekh, #1474).
-5
View File
@@ -1,5 +0,0 @@
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
American region. Rio's internal route keys are joined to SMTR's published
GTFS `routes.txt`, so a card reads "Route SN731" rather than
"Route O0731AAN0A" (IMBrandao, #1481).
-3
View File
@@ -1,3 +0,0 @@
- Write the AI HUD summary in the interface language, led by the HUD's own
translated feed-state warning. A partial layer no longer reads as off or
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
-4
View File
@@ -1,4 +0,0 @@
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
a two-way direction's half share of simulated vehicles each, so the street
is no longer drawn twice as dense as the same street from OpenStreetMap
(quanticsoul4772, #1495).
-9
View File
@@ -1,9 +0,0 @@
- Add a typed AI chat beside the voice mic in the command dock. Replies are
silent and never use the microphone; messages run the same map actions as
voice through OpenAI Chat Completions (`OPENAI_CHAT_MODEL`) or Gemini
`generateContent` (`GEMINI_CHAT_MODEL`). Chat is throttled per IP by
`GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` or `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`,
which default to that provider's voice throttle, and `GEV_PREFER_CODEX_OAUTH`
turns OpenAI chat off. The chat and voice panels minimize to a bar or dock
beside the right rail, and step aside for the Location and Visual Presets
trays (Manjunath, @manjunath22466, with Darren Dignam, @darrendignam, #1507).
+5
View File
@@ -0,0 +1,5 @@
- One world clock now drives everything with a time of its own: the sky,
the HUD clock, satellites, launches and weather. A timeline along the
bottom of the screen, shown while one of those layers is on, plays, pauses
and scrubs it, with a tick for each launch or frame and a way back to
live. Share links made away from live carry the time (samehkhamis, #1512).
@@ -1,4 +0,0 @@
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
so it stays visible over desert, snow, ocean and forest and under the FLIR,
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
#1518).
@@ -1,8 +0,0 @@
- The Global Context range ring pulses like a sonar: three dim rings with a
soft inward glow grow from the subject to 250 km, and the ring flashes as
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
get a brief echo around their brackets. The pulses run for about 9 s after
a selection or camera move, dimming the ring between flashes, then rest
with the ring steady on screen, so a parked view stops repainting. Under
reduced motion there are no pulses and the ring stays on screen
(manjunath22466, #1519).
-6
View File
@@ -1,6 +0,0 @@
- Voice "tilt the camera" works from the top-down view a fly-to lands on: the
camera swings about the place under the crosshair, keeps its heading, and can
tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt,
orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now
waits for the flight to land, and is dropped if you move the map meanwhile
(Manjunath, @manjunath22466, #1520).
-4
View File
@@ -1,4 +0,0 @@
- Cockpit Contact counts and the Contacts list name the provider that
answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from
the adsb.lol fallback no longer read OpenSky, and vessels no longer always
read AISStream (German, @gekh, #1521).
-5
View File
@@ -1,5 +0,0 @@
- Keep the globe on screen when zooming out from a tilted view. User zoom is
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
Earth stays framed. Correction finishes when zoom stops, respects tilt
gestures and stricter host limits, and yields to other camera actions
(asturksever, #1522).
-11
View File
@@ -1,11 +0,0 @@
- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned
the camera in place, so a few drags from the whole-globe view left only
stars, and the pose stayed in the share link. Free look now works only below
1,000 km, and above it a drag that starts on the stars orbits the globe
instead of turning the camera; a camera move that ends above it with no
planet surface in view (inside the scope keyhole when the scope is on) turns
straight down; and voice tilt and turns from orbit stop while the planet is
still on screen and say so instead of reporting a turn they could not make.
Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that
closed the drag path and kept authored scenes framed (Manjunath,
@manjunath22466, #1532).
@@ -1,2 +0,0 @@
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
re-enabled while a new snapshot is pending (radianx, #1535).
@@ -1,5 +0,0 @@
- Finish the typed AI chat translations. The "Reset to globe view" and "Show
earthquakes" quick commands are now translated, Traditional Chinese follows
Simplified Chinese again, Arabic docks the panel to the side it actually
lands on, and the new strings use each language's glossary terms
(German, @gekh, #1545).
-5
View File
@@ -1,5 +0,0 @@
- Keep typed chat's view when switching AI Provider: Settings opened from
chat stay chat's Settings for the new provider instead of falling back to
voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546).
@@ -1,6 +0,0 @@
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
required, and ending Contacts mid-session keeps the view and the layer it
rides; turning off the aircraft's own feed ends the view, a missing other
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
#1556).
@@ -1,3 +0,0 @@
- Chat autocomplete finds Arabic suggestions typed without short vowels or
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
(German, @gekh, #1561).
-12
View File
@@ -1,12 +0,0 @@
- Add Wildlife Sightings in the Wildlife layer group, off by default, using
keyless iNaturalist observations within 48 h (whales 7 d) and Acartia Salish
Sea marine mammals. CONFIRMED and taxa filters share via token `9`.
A noninteractive density glow accompanies real-sighting taxon badges,
decluttered species labels and credited ambient photos. Selection stays
pinned across camera and sample changes and opens an anchored photo card
with a contained image, creator/licence and one iNaturalist source link.
Obscured and Acartia sightings use text-only cards; private records and
Acartia comments are excluded. Attribution appears in the layer row and
Data attribution popover. Cached progressive tiles, bounded image/billboard
work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits.
All 11 locales supported. (lyehe, #1591)
-3
View File
@@ -1,3 +0,0 @@
- MCP tools resolve place names through the keyless Photon geocoder when the
server has no geocoder configured, instead of answering "No place matched"
for every name (asturksever, #1603).
-15
View File
@@ -1,15 +0,0 @@
- Voice can reuse what it outlines. `resolve_area` turns a named place into a
reusable area, keeping every part and hole, from the same sources as voice
outlines; a name several countries or states share ("Georgia", "Punjab") is
asked about unless the view is inside one of them. "Draw an area around
<landmark>" outlines the mapped area enclosing it, or a 250 m buffer marked
approximate. `analyst_query` counts inside an area, the area you drew or an
outline on the map, and `find_imagery` shows the best recent acquisition for
an area, the view or the spot under the pointer (manjunath22466, #846).
- `osm_query` ("hospitals in Kathmandu") lists places of one kind into a
session-only OSM Places layer. It is offered only when `OVERPASS_UPSTREAMS`
is set, and `/api/osm/features` refuses before fetching otherwise; public
Nominatim is never used for it (manjunath22466, #846).
- Analyst counts that combine flights, military aircraft and local ADS-B are
named "aircraft". Follow-ups keep the displayed scope and the loaded-data
caveat until a new scope replaces them (manjunath22466, #846).
+68 -507
View File
@@ -44,80 +44,6 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains
its distance-aware zoom, collision handling and minimum/maximum distance limits.
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera
flight, share-link and story destinations can go farther; from there, user zoom
can't climb any higher and zooms in gradually from the current height, with no
jump to the cap. Cesium can still use the limit when it computes an automatic
bounding-sphere framing distance.
Above 300 km, outward movement from the controller's configured zoom bindings
(wheel, pinch distance and unmodified right-drag by default) pitches the camera
down towards 8° below the Earth's limb. Large steps reach the limb immediately;
the remaining margin eases into place, including after zooming stops or reaches
the ceiling. Continuous outward inertia remains guarded without a time cutoff.
Two-finger tilts, modifier-specific tilts and other camera gestures cancel the
correction. A two-finger gesture counts as a pinch zoom only while the finger
spacing changes more than the fingers move up or down, so spacing drift during
a tilt is ignored. Explicit navigation handoffs, including toolbar tilt,
cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked
transforms and flights also cancel it; external pose changes cancel stationary
settling. Oblique views below 300 km are left alone.
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
the real browser controller at desktop and phone widths with keyless fixtures.
Cesium's free look (Shift+drag, which turns the camera in place) works only
below 1,000 km. Above that height a few drags could turn the whole planet out of
frame and leave only stars, so Shift+drag does nothing there. Cesium also turns
the camera in place for a drag that starts on the stars below its trackball
height (7,500 km by default); that height is lowered to 1,000 km, so such a drag
orbits the globe above it, as it does higher up, and a tilt (drag or touch)
while the screen centre is on the stars there tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the planet, and
zoom, are unchanged. How much of the frame shows the planet's surface is
measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope
mask is on (the default) only the rays inside its keyhole count, since the mask
hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above
1,000 km stop when about a tenth of the frame still shows the surface (or about
as much as it showed when it started), never less than about 1% of the visible
frame, and a view that already shows less only keeps or gains what it has. A
turn that cannot go half a degree further without crossing that limit is refused
with a reason instead of reported as done, and a turn it accepts turns at least
that half degree. A voice tilt to an angle (or a bare tilt, to -45°) that the
limit stops short of is aimed at the angle where it stops, and its result names
that angle (`pitchDeg`) with a `note`. A turn that keeps or gains surface is not refused, even after
the window has been resized, except that near the limit, where the planet's edge
crosses the sample grid, sampling wobble can occasionally refuse one (most often
above about 15,000 km and on phone or ultrawide frames); nothing then turns away
from the planet. A turn that starts where the last voice turn left the camera
keeps that turn's limit, or a higher one if the planet now fills more, so
repeating the request does not take any more; once anything else has moved or
turned the camera, a turn is measured from what is on screen. When a camera move
ends above 1,000 km with no ray on the surface, the camera turns straight down
at the same position and heading. The end of a move is taken from Cesium's
move-end event and from a check of its own that samples the pose every 250 ms
after a move starts and acts once it has held for half a second, because at some
of these poses the camera drifts by a rounding error every frame and Cesium
never sees it stop. That check keeps watching until move-end arrives, recovering
once after each move that comes to rest, and gives up five minutes after the
camera last moved. Limb glow, and a surface sliver up to about one grid cell
thick (some 50 px on a 900 px tall frame), can count as out of view. The
zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the
other ways a move can end looking away, such as a saved or shared view that did.
Views with more surface than that, such as the oblique orbital Director shots,
are left as they are, and so are tracked or locked cameras, a camera flight in
progress, a Director scene while it plays (holds included), a pointer held
down on the globe (a drag held still; with several fingers down, until the
last is released) and the 2D and Columbus modes; a check a flight, scene or
held pointer defers runs once it ends. A release outside the window that the
page never sees counts as given once the window loses focus, the page is
hidden, the canvas loses the pointer's capture, or the pointer moves with no
button down. A Director
shot loaded or replayed outside a run is not a playing scene. A view that loses
its surface without a camera move (a resize, or turning the scope on) is left as
it is until the next move. A voice turn requested while the camera is tracked is
judged by the pose voice's release of the follow camera will leave, before that
release, so a refused turn keeps the tracking.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
@@ -151,127 +77,6 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations.
## Wildlife Sightings — iNaturalist and Acartia
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
default in the Wildlife group between Events and Weather. CONFIRMED and six
taxa presets round-trip in share links. Voice is off for this layer for now;
it is outside analyst queries and DETECT. Community observations are not a
census; private records are excluded.
`/api/inaturalist/observations` requests verifiable wildlife in view within
48 h (whales 7 d). Recency uses observation time: the `d1` date filter and a
strict rolling cutoff. Date-only rows qualify only when their day starts
within the window. Close views use a cached random sample of at most 200 rows;
global samples use newest observations first. Observation caches last five
minutes (global ten minutes), with stale fallback for at most one hour.
Observations and grid tiles share a paced priority queue: a four-call burst,
then one request per second, at most four in flight, ≤60/minute and a persisted
8,000/day budget. Superseded consumers leave before dispatch. The recovered
queue tail isolates failures; ordinary grid failures do not fail observations.
Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles
retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and
known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED
with retry timing; logs exclude request URLs and upstream exception text.
A partial failure still draws what arrived: a failed iNaturalist request, or
one failed half of an antimeridian view, keeps a good Acartia reply and the
other half, retains that area's last iNaturalist rows and reports DEGRADED.
The update fails outright only when every requested source failed. Each failed
source keeps its HTTP status and `Retry-After` (the HTTP status counts when no
`X-Upstream-Status` is sent), and the next request waits out the longest 429 or
5xx cooldown among them. One update requests both sources, so the cooldown
holds the whole layer; a fully successful refresh clears it.
`/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N,
126–121° W, cached ten minutes and stale for at most 24 h. A valid empty
snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
positions are rounded to 0.01° on the server. Acartia comments are never retained.
Observer identity is retained only for photo attribution.
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
with a view-relative density ramp from cool teal to warm amber. Overlapping
Gaussian splats span four logical cell widths; mono and CRT styles follow map
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
stay inside the globe limb with a 5% radius margin; opacity fades from full at
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
add no per-frame work and leave sighting counts intact.
Only real sampled observation rows produce taxon badges at their public
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
5,000-observation cap. Tinted circles have a white Material Symbols glyph and
1.5 px dark outline, remaining 12–22 px at all distances, including selection.
Glyphs: birds `raven`, mammals `pets`, reptiles `egg_alt`, amphibians
`water_drop`, fishes `set_meal`, insects `bug_report`, arachnids `pest_control`,
plants `eco`, fungi `umbrella`, other animals `category`, unknown `question_mark`.
A plain circle displays while the icon font loads. Row counts read
“{n} sightings in view”, using visible grid totals when available.
Views request at most six tiles, coarsening zoom for complete coverage. Server
and client keep bounded 400-tile caches for 15 minutes. Cached, stale and
adjacent-zoom tiles publish before network waits; each arriving tile replaces
its covered area, including healthy empty tiles. Grid tiles publish even while
the independent observation request is pending or failed. Enable waits for one
camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The
documented UTFGrid endpoint's unspecified cell records have a schema guard;
see `KNOWN-ISSUES.md`.
Ambient cards use a fixed 96 px image width and a short creator name credit,
with a bounded frame cache. New cards fill free screen regions; visible cards
survive a random sample refresh until they leave the view or observation
window. The 110/125 km show/hide thresholds provide hysteresis; cards scale
and fade gradually through 120 km. Only painted photos suppress their own
badge and label. Labels yield to painted cards, then compete by ambient score;
repeated translated names within 80 screen pixels yield to higher-priority
content. At most 150 labels enter the host. Titles prefer a word boundary for
ellipses; accessible names remain complete. Selected thumbnails or names use
the shared selected chrome and paint lane, with no canvas source link.
Clicking a badge, label or photo flies to its supplied position and opens a
compact anchored photo card. Selection stays pinned across camera, filter and
sample changes until explicit deselection or layer disable. Its medium image
uses `object-fit: contain`, with names, compact observed age and creator/licence.
The card is the single source link to iNaturalist. Obscured sightings also stay
text-only; Acartia stays text-only with provider credit and whale guidance,
and its source link is always `https://acartia.io/`, never a contributed URL.
Placement avoids actual HUD, toolbar and rail rectangles, panning only when
necessary while preserving zoom. Escape, close or an outside click closes the
card and clears selection. Camera/layout changes reposition the card without
an idle frame listener; shell teardown also removes its host and subscription.
Attribution appears in the layer row and Data attribution popover:
“iNaturalist contributors · licences per record”, plus Acartia when drawn.
Disable/destroy releases the glow collection, texture atlas, timers, queues and
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
selection and card placement. Deterministic operation checks run in `npm test`;
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
share-link token `8`), off by default. `/api/drone-airspace` pages the public
FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R
(restricted), simplifies geometry, caches for an hour, and serves the last
good snapshot as stale when a refresh fails. The globe draws ground-clamped
outlines with a translucent fill: red for prohibited, orange for restricted.
Selecting a zone shows name, an altitude range built from the
`LOWER_*`/`UPPER_*` value, unit and reference fields (for example `SFC to
5,000 ft MSL` or `20,000 ft MSL to Unlimited`), times of use and controlling
agency,
plus a notice that the layer is informational only and not a substitute for
official UAS flight-planning tools. Pick ownership is unregistered so sibling
layer cards still work over a zone. Voice is off for this layer for now
(`VOICE_OFF_LAYERS`: the Realtime prompt has no room), so neither voice nor the
analyst can toggle or query it. The layer covers FAA prohibited and restricted
special-use airspace only, not the UAS Facility Map / LAANC grids, National
Security UAS Flight Restrictions or TFRs; the label and legend say so.
See the live-layers table below.
Vessel sources can report healthy empty coverage using a current, complete
snapshot with zero raw rows, a healthy transport and a positive last-message
time. This clears obsolete contacts and settles startup without manufacturing
@@ -880,55 +685,6 @@ announces the reason.
Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority.
Typed chat sits beside voice in the dock. Chat replies are silent and never
request microphone access. The first message connects with the selected
provider and runs the same map actions as voice through each provider's text
API: OpenAI uses `OPENAI_CHAT_MODEL` (default `gpt-4.1-mini`) and needs an API
key; Gemini sends a text-only `generateContent` request with
`GEMINI_CHAT_MODEL` (default `gemini-3.5-flash-lite`) and never starts Live.
Only one mode runs at a time: the microphone closes chat, and opening chat
stops voice. Enter sends, Shift+Enter adds a line, and Up or Down at the start
of the field recall recent messages and restore the draft. Attachments are up
to four map-layer references or UTF-8 TXT, Markdown, CSV, JSON and GeoJSON
files, 10 KB in total, sent only with the message; a layer reference names
the layer without exporting its records. The conversation and recent commands
stay in memory for the page.
The chat routes answer a missing key with `503` and code `API_KEY_MISSING`.
OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH`
pins OpenAI to ChatGPT OAuth it answers `503` with code `CODEX_OAUTH_REQUIRED`
instead; Gemini chat is unaffected. An upstream `429` or `503` keeps its status
and passes on a `Retry-After` given in seconds, capped at an hour; the chat
names the wait, and a throttled reply carries code `RATE_LIMITED`. A refused
key or account stops reaching the provider for a minute, unless the configured
key changes: `API_KEY_REJECTED` for a `401` or Gemini's `400` with reason
`API_KEY_INVALID`, `ACCESS_DENIED` for a `403`, and `QUOTA_EXCEEDED` for
OpenAI's `429` `insufficient_quota`. Each refusal answers `502` with its code,
and the chat shows a matching message.
Chat and the voice panel sit above the dock or dock into the right rail's lane,
which hides the rail until the panel minimizes or closes, or a rail panel opens.
Minimized panels always sit as a bar centred above the dock; restoring returns a
docked panel to the lane. A centred panel steps down to its bar when the pointer
reaches the Location or Visual Presets button and rests above an open tray,
without moving keyboard focus. Expanded over an open tray, it docks into the
lane until the trays close; a panel that stepped down expands in the centre
again once the trays close and the pointer leaves the wings. Panel height animates when one panel replaces the
other or the voice view changes. Minimized voice shows the last sentence from
each side, and the empty Voice Preview suggests things to say until voice
starts. Settings opened from chat keep chat's frame and show the shared AI
provider, the quick-commands option and then the voice preferences (Voice
Preview, inactivity timeout and model tier), so they stay in reach while
Preview is off, with Back to chat; voice Settings show Back to voice. Switching
AI Provider keeps that view: Settings opened from chat stay chat's Settings,
in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice
Preview, and does nothing in chat. Closing chat ends a typed session, and the
voice panel's close turns voice off. Opening chat or closing the voice panel
also clears a voice error left on the dock. Asking the assistant from chat to open
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
voice panel; the typed conversation and its running reply carry on, and the
chat button brings them back.
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
after the camera moves. An explicit new scope replaces that basis. Provider
instructions require an analyst query for requested lists and use the requested
@@ -941,9 +697,8 @@ ordinary tool-card limits are unchanged.
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
share one bounded, lossless count/scope headline; combined flight, military and
local ADS-B answers use the shared noun "aircraft"; lower bounds use "At least"
and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
share one deterministic count/scope headline; lower bounds use "At least" and
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
FIRMS and ALPR entity labels remain English in shared context and tool data;
voice-card titles and pointer presentation translate those known labels at display
time, while arbitrary names and callsigns remain unchanged.
@@ -1071,11 +826,9 @@ Source factories have dedicated `layers/<family>/source` exports. ALPR and earth
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
layer visibility lifecycle. Results distinguish successful changes from
@@ -1143,9 +896,10 @@ voice disposes the runner.
Analyst result memory is committed only after any Contacts-window projection
finishes and the call is still current. Cancelled or superseded queries cannot
return success or replace follow-up memory; Contacts follow-ups filter the same
cohort that was shown and retain its displayed scope and loaded-data note unless
the follow-up supplies a new scope. Partial/unanswered coverage remains attached
when a remembered result is narrowed.
cohort that was shown, and partial/unanswered coverage remains attached when a
remembered result is narrowed.
The narrowed answer retains the remembered scope label and detail, including
the Contacts-window attribution, unless the follow-up supplies a new scope.
Application startup constructs fresh layer instances from explicit source objects.
Standalone composition selects the existing providers. Controls, launch orbit
@@ -1443,12 +1197,12 @@ addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
longitude and latitude, or a readsb row with a hex but no `lat`/`lon`, is a
contact without a position, not a malformed row: it leaves the snapshot complete
and is listed in `retainIds`, so civil and military flights retain that aircraft
under the same five-minute bound, unless it is likely landed, while other absent
aircraft fade and leave on the missed-poll limits. A nonempty snapshot with no
positioned rows is still malformed. Unknown snapshot times remain unknown in stats. An invalid nonempty
longitude and latitude is a contact without a position, not a malformed row: it
leaves the snapshot complete and is listed in `retainIds`, so civil flights
retain that aircraft under the same five-minute bound, unless it is likely
landed, while other absent aircraft fade and leave on the missed-poll limits. A
nonempty snapshot with no positioned rows is still malformed. Military readsb
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
@@ -1642,33 +1396,6 @@ By default the 250 cameras nearest downtown Vancouver and Victoria load.
The Open Government Licence – British Columbia attribution is registered in the
Data attribution popover.
## Houston TranStar CCTV source pack
The CCTV catalog adds Greater Houston traffic cameras from Houston TranStar
alongside the other live packs. The server reads the keyless camera list at
`https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` and
keeps cameras with a numeric snapshot id inside the metro bounding box. Frame
URLs are built from each camera id under
`https://www.houstontranstar.org/snapshots/cctv/<id>.jpg`; they are never taken
from the payload. TranStar's `dir` is the side of the freeway the camera sits
on, not the way it faces; TranStar's own map reads it as the direction of the
nearest traffic (South → eastbound, North → westbound, West → southbound,
East → northbound). That travel direction seeds a low-confidence heading with
the low-confidence pose, and `None` or an unknown value uses the shared id-hash
fallback; both stay marked as estimated.
Gate: `src/data/cctvHouston.test.mjs`.
TranStar's Content Usage terms (https://traffic.houstontranstar.org/disclaimer.aspx)
prohibit copying, distribution and external framing of specific images without
TxDOT's prior written permission. The pack is off by default; operators should
set `CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
fetched and relayed on demand, buffered only transiently per request, sent with
`Cache-Control: no-store`, and not archived. Once enabled, it loads the 250
cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston.
`CCTV_HOUSTON_MAX_SOURCES` sets the pack cap (8–400). Snapshots update about
every 3 minutes. The catalog-wide ceiling stays at 4,000 and is shared
round-robin, so adding this pack does not silently drop another region.
## Location control ownership
City/POI rows, search/reset bindings, location readouts and the orbit indicator
@@ -1697,8 +1424,6 @@ are owned by a renderer-free panel component. The layer manager supplies current
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction.
The Wildlife group follows Events and precedes Weather; Wildlife Sightings
provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
## Sky background
@@ -1990,12 +1715,6 @@ unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` ov
those; exactly `0` disables the limiter, while a value that cannot be read as a
number falls back to the default rather than to unlimited. Over-limit requests
receive a sanitized `429` with `Retry-After: 5` and never reach the provider.
Typed chat (`/api/openai/chat`, `/api/gemini/chat`) counts in its own window,
set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`.
Left unset, each takes its provider's resolved cap — `GEV_RATELIMIT_OPENAI_PER_MIN`
or `GEV_RATELIMIT_GEMINI_PER_MIN`, both 30 by default — so lowering a provider
throttle lowers chat too; an explicit chat value, including `0`, wins. Its
over-limit reply carries `Retry-After: 60`.
The client key is the socket peer address only — a forwarded-for header is not
trusted, so a proxied deployment shares one bucket per upstream hop.
@@ -2349,12 +2068,7 @@ during continuous motion. Disable/re-enable resets selection cleanly.
Close-up stem sizing uses camera-to-feature distance without a 5 km minimum.
Unchanged overlay entries do not request another frame; moved positions,
changed membership and re-enabling still publish. Ground sampling waits for
visible globe tiles to settle, and skips markers whose ground the camera
cannot see (unseen ground reads through the Earth and would retry every 2 s).
Ground is tested at loaded globe terrain, or across -9,000 to 9,000 m when the
globe is hidden, so photoreal views skip only ground well out of view. Until it is
seen, a stem keeps its unsampled height, which can show for a stem whose base
is just off screen. Sampling uses valid loaded terrain as a floor while
visible globe tiles to settle and uses valid loaded terrain as a floor while
retaining rooftop and below-sea-level elevations. A hidden globe does not
constrain photoreal geometry, and retry work remains bounded. Already sampled
nearby markers follow higher settled terrain on existing bounded walks, without
@@ -2481,62 +2195,6 @@ Generated style sliders use the same name as their visible parameter label.
The first-run suppression checkbox keeps its native wrapping label, so its
accessible name remains "Don't show this again". Control behavior is unchanged.
## Active Fires intensity filter and color ramp
Every FIRMS detection is colored by fire radiative power on a six-stop ember →
white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from
50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in
`src/data/firmsIntensity.js`). Each stop is lighter than the one below and
none is a cold hue, so the brightest marks are the strongest fires; ~90% of
detections are under 20 MW and stay dim red. Glow sprites and fire-card
accents read that ramp; confidence and brightness do not affect the color.
At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style
heatmap instead of individual markers: the shown detections are linearly binned
(each detection's weight shared between the four nearest pixel centres, so spots
sit at the true position and stay round) on a
global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is
radiated power, and raising the FRP slider does not dim areas where strong fires
burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one
pixel so each fire stays a distinct spot about the size of its marker,
normalized on a log scale against the 99.5th percentile and mapped through the
same hues, from a bright red edge through orange and gold to a pale
white-hot core. The edge starts more than half opaque, so regions far below
the hottest ones still read over forest and savanna imagery. Nothing is painted where
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
on each data refresh or filter change (the first far-band paint at once,
later ones 150 ms after the last change). Heatmap and markers are both kept
built at every zoom and cross-fade by camera height between 3,600 and
5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by
imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe
tiles and re-mapped late when shown), the markers by sprite alpha, and the
marker collection is hidden once the heatmap is whole. The 750–4,500 km
markers band draws up to 40,000 detections (the strongest in the padded
view), so the weak fires the heatmap shows stay on screen as markers when it
fades out; it reads each marker's already-built anchor instead of the ground
floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest
areas; their accent is the strongest fire's FRP color.
Re-enabling Active Fires keeps the retained heat imagery hidden when the current
filtered set is empty, including while a new snapshot is pending.
The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale
whose track is painted with the ramp and ticked at the same six values (its
value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ /
High, and an "N of M shown" counter. The default is 0 MW and
All, because many of the strongest detections are rated low confidence. The
filter runs once in `ingestion.applyFilter()` before rendering, so markers,
cards, the heatmap, the shared context store, the top-fires query and the
analyst records all see one filtered set; a selected fire that no longer passes
is deselected. The layer state keeps both options under the `w` (`local-firms`)
share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`),
including the short share codec; a link without them restores the defaults.
The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence`
(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and
names the applied criteria in its notes. Stale-snapshot and missing-source
reporting are unchanged.
## FIRMS source status
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
@@ -3434,10 +3092,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
> Its Radio popover exposes compact power,
> transport, station, and volume controls. Cockpit Previous/Next changes the
> selection without starting the map-view station flights that compete with
> the first-person camera; like the Context Previous/Next buttons, it plays
> (with broadcaster fallback) only while a stream is already active. These
> transport, station, and volume controls. Cockpit Previous/Next preserves
> selection, autoplay, and broadcaster fallback audio without starting the
> map-view station flights that compete with the first-person camera. These
> are a Cockpit-only accordion:
> each static header uses the left accordion's label and divider with a dedicated
> directional-chevron disclosure button and no full-row hover treatment. Only one
@@ -3600,25 +3257,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
> (60 seconds when none, at most 10 minutes). A request whose points are all
> cached, fresh or stale, is still served; a request with any uncached point
> gets 502, whose `partial` lists the real heights it has (null for the rest)
> when it has any, and the page keeps those and uses its geoid fallback only
> for the points without one. The same holds when the upstream answers a
> point with a null height. Each request
> gets 502 and the page uses its geoid fallback for that chunk. Each request
> that was waiting on the upstream when the 429 came back logs one warning;
> requests refused during the pause do not log. Client geoid
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
> first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
> through the keyless same-site gate (reverse-proxy forwarding headers are
> admitted, as for `/api/terrain/heights`), then limits requests to 300/min
> per peer — IPv6 peers keyed by /64 — and 900/min across all remote peers
> before reading or validating bodies. Direct loopback (no forwarding
> headers) has its own 300/min window outside that global; a local reverse
> proxy's requests carry forwarding headers and are counted as remote, keyed
> `proxied:<socket peer>`, so they cannot spend the owner's window. Upstream
> quota stays shared, so owner cache misses can still hit 429.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
> POSTs through the shared local-request gate, then limits requests to 300/min
> per socket peer and 900/min globally before reading or validating bodies.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and
@@ -4342,35 +3990,31 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4398,20 +4042,6 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in
Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA
FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last
good payload as stale for at most 24 hours when a refresh fails; the layer row
then reads STALE, dated by that snapshot's fetch. An answer with no valid zone, or
paging that ends while the service still flags more records, counts as a
failed refresh. Client-supplied URLs are ignored. The route uses the keyless
same-site checks: a cross-site browser request gets `403` before it spends the
per-client quota or starts a refresh, and the app still works behind a reverse
proxy.
Polygons keep holes; pick ownership is deliberately unregistered so sibling
cards still work over a zone. The row legend and selected card state that the
layer is informational only and not a substitute for B4UFLY / LAANC.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
@@ -4803,9 +4433,8 @@ Transit is off by default, and share links use token `j`. The movement panel ord
places Transit directly after Street Traffic and before Bike Share. Cameras and
utilities retain their own groups. The layer polls
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes
@@ -5015,7 +4644,7 @@ cannot claim full acceptance.
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
prior state on exit; Space Missions refuses enablement while isolating replay
data. Cockpit does not need Contacts and does not independently enable Transit.
data. Cockpit enters through Contacts and does not independently enable Transit.
The proxy fetches registered URLs only, validates redirect origins, bounds
responses and decoding, caches successful snapshots, and backs off after failed
refreshes. A 304 confirms contact without appending history. Missing vehicles
@@ -5075,9 +4704,8 @@ programmatic activation do not trigger this reveal.
Radio directory admission is atomic on both sides of the proxy boundary. A
refresh is healthy only when it reaches the minimum accepted-query and station
coverage from broad/specialist queries; their schema-valid responses with zero
normalized stations count as failed queries rather than inflating refresh health.
Optional priority-country outcomes do not gate catalog health. Each specialist query also needs
coverage; schema-valid responses with zero normalized stations count as failed
queries rather than inflating refresh health. Each specialist query also needs
an accepted station whose normalized tags match that requested category; rows
tagged only for another category remain usable catalog data but do not earn
specialist health credit. A partial cold result remains
@@ -5108,12 +4736,10 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
ownership tokens plus the session boundary keep late callbacks from a retired
session inert.
A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
release starts playback only if a stream was active when the drag began;
otherwise it just selects the station. It commits only when the current same-ID
record still exactly matches the frozen presentation and stream metadata.
Removal or replacement reports the channel as unavailable and never silently
retargets the drag. A cold degraded fallback may
pointer-down, even while a newer catalog is admitted. Release starts playback
only when the current same-ID record still exactly matches the frozen
presentation and stream metadata; removal or replacement reports the channel as
unavailable and never silently retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner.
@@ -5290,9 +4916,7 @@ before the later control succeeds, only its playback handoff is suppressed; its
result reads enabled state from the manager and audio state from the Radio
module. Voice Pause is a playback-only no-op while the layer is disabled; it
never enables Radio and reports a fulfilled-false pause as failure. A
newer-response or user-origin Stop still cancels stale handoff work, as do a
user Pause, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory removed or changed mid-drag.
newer-response or user-origin Stop still cancels stale handoff work.
Only a newer user turn or session teardown aborts the complete active-tool set.
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
controls and generic layer-visibility commands both forward cancellation.
@@ -5333,8 +4957,6 @@ silently demoting every later lookup for the session.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
@@ -5377,10 +4999,6 @@ silently demoting every later lookup for the session.
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam
register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names
no axis, so it neither gives a capability range nor replaces the estimate. The records
alone do not enable that source pack.
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
@@ -5746,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -5787,11 +5405,8 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026)
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown.
- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that line instead of the fragment.
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer, or partial plus nominal, reads partial, not off or nominal (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
### Place-search providers
@@ -6076,7 +5691,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. Only an inconclusive timeout is retried (once); a timeout keeps every angle it measured (the settle frame before a reveal timeout, and the late frame itself, which is read before the deadline check), so a measured wrong angle fails without retry. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026)
@@ -6174,7 +5789,7 @@ apply to `public/` assets or built-preview serving.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
- The voice and chat panels share the left panels' glass surface (`--glass-bg`, `--glass-border`, blur(24px) saturate(1.4); `src/ui/styles/ai-chat.css`). The docked voice control draws its own blur on a layer behind it, so the panels inside it still blur the map. Cyber keeps its own surfaces.
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls internally.
@@ -6238,21 +5853,12 @@ apply to `public/` assets or built-preview serving.
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
tracked: Live Flights for a civilian, Military Flights for a military aircraft
(the tracked info's `layerId`; `cockpitEntryAllowed` in
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
layer, Live AIS Vessels and every other layer may be turned on or off before
or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
- Cockpit entry is gated to the operational Contacts context bundle. The
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
be enabled, and a civilian or military aircraft must be tracked. The visible
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
flight-layer selection cannot enter a context-dependent cockpit.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
@@ -6434,11 +6040,6 @@ easier to meet (detection is now on more often), but does not create it.
TomTom draws only flow-tile lines with their own flow; roads without flow
are not drawn and the status says so. A `full`-coverage flow line carries
both travel directions, a `one_side` line the direction it is drawn in.
When an opposite-direction `one_side` line runs within 5 m of at least two
of three points a quarter, half and three quarters along a `one_side` line,
the two are one street's directions and each
gets a two-way direction's half share of dots, so the street is as dense as
one `full` line (#1455).
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
runs within 35 m in the same travel direction (30 degrees), tested every
@@ -6698,46 +6299,6 @@ Existing catalog validation, category filters, tuning and voice playback behavio
remain unchanged. Audio connects directly to the broadcaster after an explicit
play action; the source does not relay or record streams.
### Radio priority-country coverage
Optional `RADIO_PRIORITY_COUNTRIES` is a server-owned comma-separated list of
up to eight ISO alpha-2 codes (for example `IN,PL`). Restart after changing it.
The `UK` alias is canonicalized to `GB` before deduplication and querying.
It needs no API key and adds no POWER UP entry. An empty list preserves the
existing global/tag-based directory. Invalid configuration fails at startup.
Each country adds one Radio Browser search capped at 200 rows, with existing
HTTPS/geolocation/codec validation and an additional matching-country check.
Up to 40 unique accepted stations per country are seeded before the specialist
tag queries and global popularity fill, within the unchanged 750-station cap.
No country gets exhaustive coverage. A valid, in-limit country response with
zero eligible stations succeeds with zero selected stations.
The response's `coverage.priorityCountries` reports accepted query status and
selected counts when configured. The existing broad/specialist health policy
still applies separately. Country outcomes are advisory: failed, malformed or
oversized country queries report unsuccessful coverage but cannot degrade or
block admission of an otherwise healthy global catalog. Queries share the existing
three-worker pool, single-flight refresh and 45-minute cache.
Structurally malformed rows are skipped and counted in the upstream
`rejectedStationCount` diagnostic; mixed country responses retain their valid
rows, while an entirely malformed nonempty country response reports unsuccessful
coverage. The diagnostic does not block catalog admission.
For every installation, including those with no priority countries configured,
at most three mirrors are tried per request; fallback mirrors beyond the first
three candidates are not attempted. Failed or partial catalog refreshes retry
after one minute, so a cold-start failure can continue returning 503 for that
minute even if upstream recovers sooner. Healthy catalogs retain the 45-minute
cache. The seven-day last-good expiry and known-station-only click reporting
remain in force.
Validation: `node --test src/data/radioProxy.test.mjs` covers configuration,
country quotas under global popularity pressure, duplicate records, concurrency,
cache reuse, country mismatch/oversize/empty/ineligible responses, failure isolation on cold
and warm refreshes beyond seven days, and retry/backoff recovery both with and
without configured countries. `node scripts/qa-radio.mjs --url http://localhost:4173` covers the existing
750-station browser presentation and playback contract.
## Local RTL-SDR and Local ADS-B
The Radio panel holds a Local RTL-SDR card below internet radio. One browser
-18
View File
@@ -10,24 +10,6 @@ For the roadmap and open backlog, see the repository issue tracker.
## Open
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
Status: Open (guarded fallback)
The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2
API docs (“Grid Tiles UTFGrid”), but the documented response only defines the
UTFGrid `grid`, `keys` and `data` members; the per-cell records are not
specified. Its 64×64 UTFGrid shape, keys and cell records
are validated in full at the allow-list proxy. A schema change or unavailable
tile retains its last good cells for at most one hour; a cold unavailable grid
uses sampled observations with the same badges and screen buckets, without
claiming complete worldwide coverage. Tests use
synthetic fixtures with the locally inspected tile's exact structure. Retest
and update the schema guard before accepting an upstream shape change. Grid
and observation calls share the paced queue, provider budget and 429 cooldown;
ordinary tile failures do not block observation queries.
### Street traffic can be slow/uneven when panning across dense city blocks
Status: Open (partially mitigated)
+5
View File
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
Field 28 is the world time the link was made at, written only when it was away
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
link's creation (a forecast moment), and it opens paused at that time.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+1 -1
View File
@@ -20,7 +20,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
| `gevActions.js` runner | Area handles (page lifetime); analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
`backend` option and falls back to `createRealtimeBackend()`. Its two method
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+3 -7
View File
@@ -2,7 +2,7 @@
"name": "gods-eye-view",
"private": true,
"version": "0.3.0",
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
"author": "Bilawal Sidhu",
@@ -47,7 +47,6 @@
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:perf": "node scripts/run-perf-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs",
@@ -90,6 +89,7 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
@@ -177,7 +177,6 @@
"./layers/perimeters": "./src/layers/perimeters/index.js",
"./layers/gnss": "./src/layers/gnss/index.js",
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
"./layers/drone-airspace": "./src/layers/droneAirspace/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
@@ -257,7 +256,6 @@
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
"./layers/gnss/source": "./src/layers/gnss/source.js",
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
@@ -293,8 +291,6 @@
"./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js",
"./layers/wildlife-sightings": "./src/layers/wildlifeSightings/index.js",
"./layers/wildlife-sightings/source": "./src/layers/wildlifeSightings/source.js"
"./agent/world-packet": "./src/agent/worldPacket.js"
}
}
-4
View File
@@ -30,7 +30,3 @@ PINOKIO_SHARE_VAR=__gev_sharing_disabled__
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
GEV_RATELIMIT_OPENAI_PER_MIN=30
GEV_RATELIMIT_GOOGLE_PER_MIN=120
# Typed chat follows the OpenAI cap above, or Gemini voice's default of 30,
# unless set here; 0 disables it.
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=
-2
View File
@@ -28,8 +28,6 @@ module.exports = {
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-install.mjs',
},
-2
View File
@@ -23,8 +23,6 @@ module.exports = {
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-start.mjs',
on: [{
-2
View File
@@ -19,8 +19,6 @@ module.exports = {
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
},
message: 'node scripts/pinokio-update.mjs',
},
+1 -152
View File
@@ -57,9 +57,6 @@
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/app/zoomOutGuard.test.mjs",
"src/app/controls.test.mjs",
"scripts/qa-zoom-out-guard.mjs",
"src/app/atmosphereCompat.test.mjs",
"src/standalone/application.js",
"src/standalone/controls.js",
@@ -405,22 +402,12 @@
"src/layers/perimeters/cards.test.mjs",
"src/layers/perimeters/inciweb.test.mjs",
"src/layers/perimeters/ownership.test.mjs",
"src/layers/droneAirspace/index.js",
"src/layers/droneAirspace/records.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/cards.js",
"src/layers/droneAirspace/records.test.mjs",
"src/layers/droneAirspace/source.test.mjs",
"src/layers/droneAirspace/cards.test.mjs",
"src/layers/droneAirspace/ownership.test.mjs",
"src/layers/droneAirspace/docs.test.mjs",
"src/data/firmsHeatmap.js",
"src/data/fireAnchors.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/heatVisibility.test.mjs",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
@@ -582,7 +569,6 @@
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
@@ -695,7 +681,6 @@
"src/app/layers/radio.js",
"src/app/layers/earthquakes.js",
"src/app/layers/perimeters.js",
"src/app/layers/droneAirspace.js",
"src/app/layers/cctv.js",
"src/app/layers/firms.js",
"src/app/layers/satellites.js",
@@ -786,7 +771,6 @@
"server/providers/transit.js",
"server/providers/transitHistory.js",
"server/providers/transitHistory.test.mjs",
"src/data/rioRouteNames.js",
"src/data/transitFeeds.js",
"src/data/transitFeeds.test.mjs",
"src/data/transitProxy.js",
@@ -882,7 +866,6 @@
"src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js",
"server/providers/firePerimeters.js",
"server/providers/droneAirspace.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js",
@@ -907,7 +890,6 @@
"src/layers/ghostMaps/records.test.mjs",
"src/layers/ghostMaps/lifecycle.test.mjs",
"scripts/qa-ghostmaps.mjs",
"src/data/droneAirspaceProxy.test.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -1120,21 +1102,15 @@
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/cameraTilt.test.mjs",
"src/ui/shellFacade.test.mjs",
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/hudSummaryLine.test.mjs",
"src/testSupport/stubMgrs.mjs",
"src/tools/queries/hud-caption-feed-state.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
@@ -1201,9 +1177,6 @@
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/awarenessEcho.js",
"src/awarenessEcho.test.mjs",
"src/data/detectionAwarenessEcho.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
@@ -1214,10 +1187,6 @@
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/data/firmsIntensity.js",
"src/data/firmsIntensity.test.mjs",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
@@ -1234,125 +1203,5 @@
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs",
"server/providers/common/rateLimitKeys.test.mjs",
"src/voice/areaCandidateContract.js",
"src/voice/layerActionOwnership.js",
"src/data/areaGeometry.test.mjs",
"src/data/areaResolver.test.mjs",
"src/sources/osmFeatures.test.mjs",
"src/layers/osmPlaces/index.test.mjs",
"src/tooling/boundedCache.test.mjs",
"src/tooling/osmFeaturesRoute.test.mjs",
"src/voice/areaActions.test.mjs",
"src/voice/imageryActions.test.mjs",
"src/voice/osmActions.test.mjs",
"src/voice/realtimeProtocol.test.mjs",
"src/layers/awareness/rangeRing.test.mjs",
"src/voice/voiceMiniPreview.js",
"src/voice/voiceMiniPreview.test.mjs",
"src/voice/chatPreferences.test.mjs",
"src/voice/textChat.test.mjs",
"src/voice/chatAttachments.test.mjs",
"src/voice/chatControlView.test.mjs",
"src/textChatServer.test.mjs",
"src/geminiChatServer.test.mjs",
"src/voice/geminiTextChat.test.mjs",
"server/providers/common/textChat.js",
"server/providers/common/textInstructions.js",
"server/providers/common/textTools.js",
"server/providers/gemini/chat.js",
"server/providers/openai/chat.js",
"src/ui/styles/ai-chat.css",
"src/voice/chatAttachments.js",
"src/voice/chatControl.js",
"src/voice/chatPreferences.js",
"src/voice/geminiTextChat.js",
"src/voice/panelLayout.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js",
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/same-as-english.json",
"src/navigationPolicy.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayDraw.test.mjs",
"src/sources/rateLimit.js",
"src/ui/layerNames.js",
"src/ui/styles/cyber.css",
"src/voice/layerManifest.js",
"src/worldFocus.js",
"src/worldFocus.test.mjs",
"server/providers/acartia.js",
"server/providers/acartia.test.mjs",
"server/providers/common/userAgent.js",
"server/providers/inaturalist/client.js",
"server/providers/inaturalist/observations.js",
"server/providers/inaturalist/observations.test.mjs",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/cards.test.mjs",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/docs.test.mjs",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/model.test.mjs",
"src/layers/wildlifeSightings/ownership.test.mjs",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/photos.test.mjs",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/records.test.mjs",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/source.test.mjs",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/viewport.test.mjs",
"src/sources/rateLimit.test.mjs",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/acquisition.test.mjs",
"server/providers/inaturalist/client.test.mjs",
"src/layers/wildlifeSightings/acartiaTaxa.test.mjs",
"src/ui/wildlifeSightingCard.test.mjs",
"src/overlays/wildlifeSightingOverlay.test.mjs",
"scripts/run-perf-tests.mjs",
"src/perfTestRunner.test.mjs",
"src/testSupport/wildlifeBillboardModel.mjs",
"src/layers/wildlifeSightings/gridViewport.test.mjs",
"src/layers/wildlifeSightings/rendering.test.mjs",
"src/layers/wildlifeSightings/sprites.test.mjs",
"src/layers/wildlifeSightings/performance.test.mjs",
"src/layers/wildlifeSightings/model.perf.test.mjs",
"src/ui/wildlifeSightingPlacement.js",
"server/providers/inaturalist/queue.js",
"server/providers/inaturalist/queue.test.mjs",
"server/providers/inaturalist/grid.js",
"server/providers/inaturalist/grid.test.mjs",
"server/providers/inaturalist/grid.fixture.mjs",
"src/layers/wildlifeSightings/performance.js",
"src/layers/wildlifeSightings/renderPerformanceFixtures.mjs",
"server/providers/inaturalist/speed.test.mjs",
"src/layers/wildlifeSightings/render.perf.test.mjs",
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs"
"src/tooling/nominatimOutlineRoute.test.mjs"
]
+42 -325
View File
@@ -30,8 +30,6 @@
"src/ui/views/panels/LocalePicker.vue",
"src/ui/views/panels/SceneDialog.vue",
"src/ui/views/panels/VoiceControl.vue",
"src/ui/views/panels/ChatPanel.vue",
"src/ui/views/panels/PanelLayoutButtons.vue",
"src/ui/views/panels/VoiceCard.vue",
"src/ui/views/panels/VoiceVisualizer.vue",
"src/ui/localePicker.js",
@@ -39,9 +37,6 @@
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/ui/views/panels/CockpitRollingValue.vue",
"src/ui/views/panels/CockpitSignalList.vue",
"src/ui/views/panels/CockpitUtilityControls.vue",
@@ -155,16 +150,23 @@
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
"src/ui/i18n.js",
"src/ui/surfaceKeyboard.js"
"src/time/worldClock.js",
"src/ui/kit/UiIcon.vue",
"src/ui/stores/worldTime.js",
"src/ui/views/panels/WorldTimeline.vue"
],
"external": ["vue", "pinia", "vue-i18n"]
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
"external": []
},
"viewer": {
"exports": ["./application/viewer"],
"modules": [
"src/app/viewer.js",
"src/app/atmosphereCompat.js",
"src/globeInView.js",
"src/sky/starrySky.js",
"src/sky/stars.js",
"src/data/bundledJson.js",
@@ -204,22 +206,7 @@
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/acartia.js",
"server/providers/common/rate-limit.js",
"server/providers/common/same-site.js",
"server/providers/common/userAgent.js",
"server/providers/inaturalist/client.js",
"server/providers/inaturalist/observations.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/policy.js",
"src/localRequestGate.mjs",
"src/sources/rateLimit.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"server/providers/inaturalist/grid.js",
"src/layers/wildlifeSightings/gridViewport.js",
"server/providers/inaturalist/queue.js"
"src/i18n/marker.js"
],
"external": ["ws"]
},
@@ -438,11 +425,7 @@
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/overpass.js",
"src/sources/rateLimit.js",
"server/providers/common/boundedCache.js",
"src/data/osmPresets.js",
"server/providers/overpass/osmFeatures.js",
"server/providers/overpass/osmFeaturesRoute.js"
"src/sources/rateLimit.js"
],
"external": []
},
@@ -511,20 +494,10 @@
"exports": ["./server/providers/openai"],
"modules": [
"src/i18n/marker.js",
"src/i18n/locale.js",
"src/hudLayoutPolicy.js",
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
"server/providers/common/source-root.js",
"server/providers/common/textChat.js",
"server/providers/common/textInstructions.js",
"server/providers/common/textTools.js",
"server/providers/openai/chat.js",
"server/providers/gemini/request.js",
"build/allowedHosts.js",
"src/tools/schema.js",
"src/sources/requestDeadline.js",
"src/sources/httpBody.js",
"server/providers/openai.js",
"server/providers/openai/codex-auth.js",
"server/providers/openai/constants.js",
@@ -543,11 +516,9 @@
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"src/voice/actionSchemas.js",
"src/voice/areaCandidateContract.js",
"src/voice/layerManifest.js",
"server/providers/openai/toolDescriptions.js",
"src/sources/rateLimit.js",
"server/providers/overpass/constants.js"
"src/sources/rateLimit.js"
],
"external": []
},
@@ -705,8 +676,6 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/clearLayersControl.js",
"src/loadingFeedback.js",
@@ -744,8 +713,7 @@
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiBadge.vue",
"src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSegmented.vue"
"src/ui/kit/UiSlider.js"
],
"external": ["vue"]
},
@@ -889,7 +857,6 @@
"src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
@@ -912,7 +879,6 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -971,7 +937,6 @@
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1018,7 +983,6 @@
"src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
@@ -1045,8 +1009,6 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
@@ -1171,14 +1133,7 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/ui/kit/UiSegmented.vue",
"src/ui/wildlifeSightingCard.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/views/panels/WildlifeSightingCard.vue",
"src/ui/wildlifeSightingPlacement.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js"
"src/time/worldClock.js"
],
"external": ["cesium", "vue", "pinia", "vue-i18n"]
},
@@ -1347,7 +1302,6 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -1390,7 +1344,9 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js"
],
"external": ["cesium"]
},
@@ -1410,7 +1366,6 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -1447,7 +1402,9 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js",
"src/time/julian.js"
],
"external": ["cesium"]
},
@@ -1464,7 +1421,6 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1566,8 +1522,6 @@
"exports": ["./layers/firms"],
"modules": [
"src/data/firmsAdapt.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/iconOrientation.js",
"src/layers/firms/anchors.js",
@@ -1579,7 +1533,6 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -1636,7 +1589,8 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/time/worldClock.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -1676,7 +1630,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": ["cesium"]
},
@@ -1738,7 +1693,6 @@
"src/data/distanceFormat.js",
"src/i18n/marker.js",
"src/cyberSonar.js",
"src/awarenessEcho.js",
"src/cockpitMath.js",
"src/data/iconOrientation.js",
"src/data/militaryAwarenessEngine.js",
@@ -1774,10 +1728,6 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -2001,7 +1951,8 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/time/worldClock.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -2047,7 +1998,6 @@
"modules": [
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -2199,14 +2149,6 @@
"src/voice/realtimeBackend.js",
"src/voice/cloudVoiceAuth.js",
"src/voice/control.js",
"src/voice/chatControl.js",
"src/voice/geminiTextChat.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js",
"src/voice/chatAttachments.js",
"src/voice/chatPreferences.js",
"src/voice/panelLayout.js",
"src/voice/voiceMiniPreview.js",
"src/voice/layerSummary.js",
"src/voice/voiceCost.js",
"src/voice/commands.js",
@@ -2302,7 +2244,6 @@
"src/ui/placeNames.js",
"src/cyberSonarScene.js",
"src/cyberSonar.js",
"src/awarenessEcho.js",
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
"src/ui/radioPanelPlacement.js",
@@ -2349,7 +2290,6 @@
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js",
"src/app/zoomOutGuard.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
"src/app/startupChrome.js",
@@ -2364,7 +2304,6 @@
"src/camera.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/globeInView.js",
"src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js",
"src/celestialRing.js",
@@ -2421,8 +2360,6 @@
"src/data/fireAnchors.js",
"src/data/firmsAdapt.js",
"src/data/firmsHeatmap.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/flights.js",
"src/data/flowMatch.js",
@@ -2432,7 +2369,6 @@
"src/data/groundSnap.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/labelArbiter.js",
"src/data/idSwitches.js",
@@ -2584,7 +2520,6 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -2846,8 +2781,6 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
@@ -2896,14 +2829,6 @@
"src/voice/panel/nativeActivity.js",
"src/voice/panel/preferences.js",
"src/voice/control.js",
"src/voice/chatControl.js",
"src/voice/geminiTextChat.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js",
"src/voice/chatAttachments.js",
"src/voice/chatPreferences.js",
"src/voice/panelLayout.js",
"src/voice/voiceMiniPreview.js",
"src/voice/gevActions.js",
"src/agent/worldPacket.js",
"src/voice/speech.js",
@@ -2974,11 +2899,6 @@
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js",
"src/app/layers/droneAirspace.js",
"src/layers/droneAirspace/index.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
"src/layers/droneAirspace/cards.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2989,7 +2909,6 @@
"src/app/layers/transit.js",
"src/data/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
@@ -3273,6 +3192,9 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
"src/time/viewerClock.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3280,45 +3202,7 @@
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js",
"src/sources/osmFeatures.js",
"src/voice/imageryActions.js",
"src/voice/layerActionOwnership.js",
"src/data/areaGeometry.js",
"src/layers/osmPlaces/model.js",
"src/data/areaStore.js",
"src/data/areaResolver.js",
"src/voice/areaCandidateContract.js",
"src/data/osmPresets.js",
"src/voice/osmActions.js",
"src/layers/osmPlaces/index.js",
"src/app/layers/osmPlaces.js",
"src/voice/areaActions.js",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/wildlifeSightingCard.js",
"src/ui/views/panels/WildlifeSightingCard.vue",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/ui/wildlifeSightingPlacement.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
"src/data/local_data/wof_neighborhoods/files.js"
],
"external": [
"hls.js",
@@ -3343,13 +3227,10 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/cyberSonar.js",
"src/awarenessEcho.js",
"src/data/approxDistance.js",
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
@@ -3415,15 +3296,12 @@
"src/data/detectionDraw.js",
"src/data/detectionPolicy.js",
"src/data/firmsAdapt.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/flowMatch.js",
"src/data/focusDeemphasis.js",
"src/data/geoid.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/osmOverlays.js",
"src/data/installationFeedback.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
@@ -3548,7 +3426,6 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -3721,9 +3598,6 @@
"src/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js",
"src/cameraVerbs.js",
"src/globeInView.js",
"src/scopeMaskPolicy.js",
"src/scopeMask.js",
"src/services/groundFloor.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
@@ -3756,11 +3630,6 @@
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/source.js",
"src/app/layers/droneAirspace.js",
"src/layers/droneAirspace/index.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
"src/layers/droneAirspace/cards.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -3770,7 +3639,6 @@
"src/sources/nominatim.js",
"src/app/layers/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
@@ -3934,6 +3802,8 @@
"src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js",
"src/ui/i18n.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3941,33 +3811,7 @@
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js",
"src/layers/osmPlaces/model.js",
"src/layers/osmPlaces/index.js",
"src/app/layers/osmPlaces.js",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/ui/rail/utcClock.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
"src/data/local_data/wof_neighborhoods/files.js"
],
"external": [
"hls.js",
@@ -4099,7 +3943,6 @@
"src/i18n/marker.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -4120,7 +3963,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": []
},
@@ -4333,7 +4177,6 @@
"exports": ["./voice/action-schemas"],
"modules": [
"src/voice/actionSchemas.js",
"src/voice/areaCandidateContract.js",
"src/voice/layerManifest.js",
"src/hudLayoutPolicy.js"
],
@@ -4351,15 +4194,6 @@
"src/voice/sessionCommands.js",
"src/voice/session.js",
"src/voice/control.js",
"src/voice/chatControl.js",
"src/voice/cloudVoiceAuth.js",
"src/voice/geminiTextChat.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js",
"src/voice/chatAttachments.js",
"src/voice/chatPreferences.js",
"src/voice/panelLayout.js",
"src/voice/voiceMiniPreview.js",
"src/voice/voiceCard.js",
"src/voice/voiceCardPresentation.js",
"src/voice/resultDisplay.js",
@@ -4501,17 +4335,7 @@
"src/layers/submarineCables/bundledSource.js",
"src/layers/gnss/source.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/performance.js"
"src/layers/airQuality/source.js"
],
"external": []
},
@@ -4563,7 +4387,6 @@
"server/providers/transitHistory.js",
"src/data/gtfsRealtime.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/sources/httpBody.js",
@@ -4580,7 +4403,6 @@
"modules": [
"src/data/gtfsRealtime.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/sources/transitHistory.js"
@@ -4614,7 +4436,6 @@
"src/data/terrainHeights.js",
"src/data/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/data/transitProxy.js",
@@ -4663,7 +4484,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/overlays/worldOverlayChrome.js"
"src/time/worldClock.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -4674,7 +4495,6 @@
"src/data/iconOrientation.js",
"src/data/inputOwnership.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
@@ -4716,7 +4536,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
],
"external": ["cesium", "pbf"]
},
@@ -4732,7 +4553,6 @@
"exports": ["./sources/transit-service"],
"modules": [
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/data/gtfsRealtime.js",
@@ -4831,14 +4651,14 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/view/index.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/idSwitches.js",
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js",
"src/visualStylePolicy.js"
"src/visualStylePolicy.js",
"src/time/worldClock.js"
],
"external": []
},
@@ -4898,41 +4718,6 @@
],
"external": []
},
"drone-airspace": {
"exports": ["./layers/drone-airspace"],
"modules": [
"src/layers/droneAirspace/index.js",
"src/sources/httpBody.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
"src/i18n/marker.js",
"src/layers/droneAirspace/cards.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium"]
},
"drone-airspace-source": {
"exports": ["./layers/drone-airspace/source"],
"modules": [
"src/layers/droneAirspace/records.js",
"src/sources/httpBody.js",
"src/layers/droneAirspace/source.js",
"src/i18n/marker.js"
],
"external": []
},
"tools": {
"exports": ["./tools"],
"modules": [
@@ -4953,8 +4738,6 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/firmsIntensity.js",
"src/data/firmsAdapt.js",
"src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js",
@@ -4977,7 +4760,6 @@
"src/tools/surfaces.js",
"src/layers/transit/policy.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/layers/transit/movement.js",
"src/data/contactPlayback.js",
"src/data/layerSnapshot.js",
@@ -5081,8 +4863,7 @@
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js",
"src/sources/infrastructureData.js",
"src/sources/eventPacks.js",
"src/keylessGeocoder.js"
"src/sources/eventPacks.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
@@ -5091,7 +4872,6 @@
"modules": [
"src/view/index.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/visualStylePolicy.js"
@@ -5146,68 +4926,5 @@
"src/i18n/marker.js"
],
"external": ["cesium"]
},
"wildlife-sightings": {
"exports": ["./layers/wildlife-sightings"],
"modules": [
"src/data/dataCredits.js",
"src/data/iconOrientation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/marker.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js",
"src/sources/httpBody.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
],
"external": ["cesium"]
},
"wildlife-sightings-source": {
"exports": ["./layers/wildlife-sightings/source"],
"modules": [
"src/i18n/marker.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/sources/httpBody.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/performance.js"
],
"external": []
}
}
-2
View File
@@ -19,8 +19,6 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'LL2_API_TOKEN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GOOGLE_PER_MIN',
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
'PINOKIO_SHARE_CLOUDFLARE',
'PINOKIO_SHARE_LOCAL',
'PINOKIO_SHARE_VAR',
+3 -20
View File
@@ -3073,27 +3073,20 @@ async function main() {
expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples,
spread: Math.max(...samples) - Math.min(...samples),
};
});
check(
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
'pointer preview never plays and release commits the nearest station once without a needle sweep',
tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.commitPlayDelta === 1
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3501,9 +3494,6 @@ async function main() {
});
});
await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
@@ -3702,14 +3692,7 @@ async function main() {
radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
// Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
+14 -235
View File
@@ -29,7 +29,6 @@
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
* node scripts/qa-voice-routing.mjs --layer behavior --behavior geometry # areas, drawn counts, imagery
*
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
* suites must run sequentially with other harnesses (SwiftShader saturation);
@@ -76,7 +75,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
const TURN_BUDGET = Number(getOpt('--budget', '120'));
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
const ONLY = getOpt('--only', null); // substring filter on phrase text
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance | geometry
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
@@ -165,11 +164,7 @@ async function mintSession() {
throw new Error(
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
);
// The tools this server offered (osm_query only with an operator Overpass).
const tools = new Set(
(body?.session?.tools || []).map((tool) => tool?.name).filter(Boolean),
);
return { value, model, tools };
return { value, model };
}
/** One Realtime WS session that can run several text turns sequentially. */
@@ -376,10 +371,8 @@ async function runRoutingLayer() {
}
const batch = list.slice(i, i + PHRASES_PER_SESSION);
let session;
let offered = new Set();
try {
const { value, model, tools } = await mintSession();
offered = tools;
const { value, model } = await mintSession();
session = new RoutingSession(value, model, evidence);
await session.connect();
} catch (e) {
@@ -397,14 +390,6 @@ async function runRoutingLayer() {
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
continue;
}
const missing = (p.requires || []).filter((name) => !offered.has(name));
if (missing.length) {
skipped(
`route: "${p.phrase}"`,
`${missing.join(', ')} not offered (no configured Overpass)`,
);
continue;
}
turnsUsed += 1;
let calls = [];
try {
@@ -1376,11 +1361,7 @@ async function runBehaviorLayer() {
// The point-and-ask block skips the first-run launcher so its evidence
// screenshots show the map; the full run keeps the historical URL.
const behaviorUrl = new URL(APP_URL);
if (
DEIXIS_ONLY ||
BEHAVIOR === 'acceptance' ||
BEHAVIOR === 'geometry'
)
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
behaviorUrl.searchParams.set('welcome', '0');
await page.goto(behaviorUrl.href, {
waitUntil: 'domcontentloaded',
@@ -1439,10 +1420,6 @@ async function runBehaviorLayer() {
await runOutlineAcceptance({ page, run, settle, overpassRequests });
return;
}
if (BEHAVIOR === 'geometry') {
await runGeometryScenarios({ page, run, settle, overpassRequests });
return;
}
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
// while the camera is local — the resolver's proximity gate rejects far
@@ -2321,11 +2298,9 @@ async function runBehaviorLayer() {
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
);
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
// With Contacts OFF, status must explain a shut gate without blaming
// Contacts, and entry must either enter cleanly or refuse with the reason
// rather than produce the half-entered state the operator saw: a plane
// anchored under the camera with no HUD and no exit control.
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
// reason rather than produce the half-entered state the operator saw: a
// plane anchored under the camera with no HUD and no exit control.
r = await run('control_cockpit', { action: 'status' });
const gateReport = r?.state || {};
const cockpitBeforeGate = await page.evaluate(() =>
@@ -2333,13 +2308,10 @@ async function runBehaviorLayer() {
);
report(
gateReport.active === false &&
(gateReport.entryAllowed === true
? gateReport.entryBlockedReason === null
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
gateReport.entryBlockedReason,
)) &&
gateReport.entryAllowed === false &&
gateReport.entryBlockedReason === 'contacts-inactive' &&
!cockpitBeforeGate,
'behavior: cockpit status explains its gate without requiring Contacts',
'behavior: cockpit status names why entry is blocked with Contacts off',
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
);
@@ -2353,16 +2325,12 @@ async function runBehaviorLayer() {
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
);
report(
gated?.ok === true
? cockpitAfterGate
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
gated?.ok === false &&
/contacts/i.test(gated?.error || '') &&
!cockpitAfterGate,
'behavior: cockpit entry with Contacts off is refused, not half-entered',
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
if (cockpitAfterGate)
await page.evaluate(() =>
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
);
await runBundledOutlineScenarios({
page,
@@ -2372,8 +2340,6 @@ async function runBehaviorLayer() {
resolverLog,
packTimes,
});
// Voice geometry last: it moves the camera and turns layers off.
await runGeometryScenarios({ page, run, settle, overpassRequests });
const shotDir = path.join(ROOT, 'qa-shots');
fs.mkdirSync(shotDir, { recursive: true });
@@ -2517,193 +2483,6 @@ const OUTLINE_ASKS = [
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
/**
* Voice geometry, runner-driven: reusable areas, drawn-area counts, a
* landmark area and recent imagery, all without a default Overpass request.
* Public Nominatim use is read from the server's daily count.
*/
async function runGeometryScenarios({ page, run, settle, overpassRequests }) {
const shotDir = path.join(ROOT, 'qa-shots', 'pr-geometry');
fs.mkdirSync(shotDir, { recursive: true });
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height,
}),
orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 },
});
}, { lat, lon, height, pitch });
const timings = {};
// (G1) "Outline Bagmati, then count fires inside it".
await teleport(27.7, 85.4, 400_000);
await settle(1500);
let t0 = Date.now();
const bagmati = await run('resolve_area', { query: 'Bagmati province', draw: true, flyTo: false });
timings.bagmatiMs = Date.now() - t0;
const bagmatiRefused = [
'AREA_IDENTITY_MISMATCH',
'AREA_IDENTITY_UNVERIFIED',
].includes(bagmati?.code);
const bagmatiVerified =
bagmati?.ok === true &&
bagmati?.drawn === true &&
bagmati?.level === 'admin1' &&
bagmati?.name === 'Bagmati Province' &&
bagmati?.sourceIdentity === 'Bagmati Province';
report(
bagmatiVerified || bagmatiRefused,
'geometry: "outline Bagmati province" uses verified current identity or refuses honestly',
`areaId=${bagmati?.areaId} name=${bagmati?.name} sourceIdentity=${bagmati?.sourceIdentity} source=${bagmati?.source} km2=${bagmati?.areaKm2} ms=${timings.bagmatiMs} code=${bagmati?.code || ''} ${bagmati?.error || ''}`,
);
await settle(3000);
await shot('bagmati-outline');
t0 = Date.now();
let r = await run('resolve_area', { query: 'Bagmati province' });
report(
bagmatiVerified
? r?.ok === true &&
r?.cached === true &&
r?.ms < 300 &&
r?.sourceIdentity === 'Bagmati Province'
: bagmatiRefused &&
r?.ok === false &&
r?.code === bagmati?.code &&
!r?.areaId,
'geometry: a repeated Bagmati request reuses only verified identity, never refused geometry',
`ok=${r?.ok} code=${r?.code || ''} areaId=${r?.areaId || ''} sourceIdentity=${r?.sourceIdentity || ''} cached=${r?.cached} ms=${r?.ms} roundTrip=${Date.now() - t0}`,
);
// A city no bundled pack holds: the guarded Nominatim outline route.
t0 = Date.now();
const kathmandu = await run('resolve_area', { query: 'Kathmandu', draw: true, flyTo: false });
timings.kathmanduMs = Date.now() - t0;
report(kathmandu?.ok === true && kathmandu?.drawn === true && kathmandu?.source === 'osm' && kathmandu?.areaKm2 > 10 && kathmandu?.areaKm2 < 2000,
'geometry: "outline Kathmandu" comes from the guarded Nominatim outline',
`areaId=${kathmandu?.areaId} source=${kathmandu?.source} km2=${kathmandu?.areaKm2} parts=${kathmandu?.parts} ms=${timings.kathmanduMs} err=${kathmandu?.code || ''} ${kathmandu?.error || ''}`);
await settle(2500);
await shot('kathmandu-outline');
r = await run('resolve_area', { query: 'Georgia' });
report(r?.ok === false && r?.needsClarification === true && (r?.candidates || []).length >= 2,
'geometry: an ambiguous area asks with candidates instead of guessing',
`candidates=${(r?.candidates || []).map((c) => `${c.name}/${c.level}/${c.country || ''}`).join(', ')}`,
);
if (bagmatiVerified) {
await run('set_layer_visibility', {
layerId: 'local-firms',
enabled: true,
});
let fires = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
fires = await run('analyst_query', {
layers: ['local-firms'],
scope: { kind: 'area', areaId: bagmati.areaId },
});
if (fires?.ok || !['NOT_READY', 'LAYER_OFF'].includes(fires?.code)) break;
await settle(4000);
}
if (fires?.code === 'FEED_UNAVAILABLE') {
skipped(
'geometry: "count fires inside it" counts inside the verified Bagmati area',
`fires feed unavailable: ${fires?.error}`,
);
} else {
report(
fires?.ok === true &&
Number.isFinite(fires?.count) &&
/Bagmati/i.test(fires?.scopeLabel || '') &&
fires?.coverage?.scope === `area:${bagmati.areaId}`,
'geometry: "count fires inside it" counts inside the verified Bagmati area',
`count=${fires?.count} scopeLabel="${fires?.scopeLabel}" scope=${fires?.coverage?.scope} err=${fires?.error || ''}`,
);
}
await run('set_layer_visibility', {
layerId: 'local-firms',
enabled: false,
});
} else {
skipped(
'geometry: fire count waits for a verified Bagmati area',
`boundary refused with ${bagmati?.code || 'unknown error'}`,
);
}
// (G2) "Count flights inside the area I drew": the draw tool's own spec.
await teleport(30.27, -97.74, 600_000);
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
for (let attempt = 0; attempt < 12; attempt += 1) {
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
if (r?.ok && r.count > 0) break;
await settle(5000);
}
const drawnBox = [[-99, 29], [-96.5, 29], [-96.5, 31.5], [-99, 31.5], [-99, 29]];
await page.evaluate((ring) => window.__gevAnnotations.annotate([{ type: 'area', manual: true, ring, label: 'QA drawn box' }]), drawnBox);
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'drawn' } });
const loaded = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
report(r?.ok === true && r?.scopeLabel === 'inside QA drawn box' && Number.isFinite(r?.count) && r.count <= (loaded?.count ?? Infinity),
'geometry: "count flights inside the area I drew" counts inside the drawn outline',
`count=${r?.count} of ${loaded?.count} loaded scopeLabel="${r?.scopeLabel}" err=${r?.error || ''}`);
await settle(2500);
await shot('drawn-area-count');
// Thousands of aircraft starve a SwiftShader page; the landmark lookup
// below has a 10 s budget.
await run('set_layer_visibility', { layerId: 'flights', enabled: false });
await run('clear_annotations', {});
// (G3) "Draw an area around the Ferry Building": the enclosing mapped area
// (Nominatim grounds, then the OpenFreeMap area holding it), else an
// approximate buffer — either way a drawn, reusable area.
await teleport(37.7955, -122.3937, 2500, -60);
await settle(1500);
t0 = Date.now();
const ferry = await run('resolve_area', { query: 'Ferry Building, San Francisco', around: true, flyTo: false });
timings.ferryMs = Date.now() - t0;
const ferryNear = Array.isArray(ferry?.bbox) && Math.abs(ferry.bbox[1] - 37.795) < 0.05 && Math.abs(ferry.bbox[0] + 122.394) < 0.05;
report(ferry?.ok === true && ferry?.drawn === true && ferryNear && (ferry?.approximate === true || ['osm', 'openfreemap'].includes(ferry?.source)),
'geometry: "draw an area around the Ferry Building" draws a reusable landmark area',
`areaId=${ferry?.areaId} source=${ferry?.source} approximate=${ferry?.approximate} basis=${ferry?.display?.basis} km2=${ferry?.areaKm2} ms=${timings.ferryMs} err=${ferry?.error || ''}`);
await settle(3000);
await shot('ferry-building-area');
// (G4) "Find recent imagery of this area": the Recent Imagery catalog for
// that area, the best acquisition pinned through the layer itself.
t0 = Date.now();
r = await run('find_imagery', { areaId: ferry?.areaId || 'missing' });
timings.imageryMs = Date.now() - t0;
const pinned = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('recent-imagery')?.module?.getSnapshot?.().pins?.a?.key || null);
if (r?.code === 'CATALOG_UNAVAILABLE' || r?.code === 'SEARCH_TIMEOUT') {
skipped('geometry: "find recent imagery of this area" pins the best acquisition', `catalog: ${r?.error}`);
} else {
report(r?.ok === true && (r?.count === 0 ? r?.shown === null : Boolean(r?.shown?.date) && String(pinned).endsWith(`:${r.shown.date}`)),
'geometry: "find recent imagery of this area" pins the best acquisition',
`count=${r?.count} shown=${JSON.stringify(r?.shown)} pinned=${pinned} ms=${timings.imageryMs} err=${r?.error || ''}`);
}
await settle(4000);
await shot('recent-imagery-area');
await run('set_layer_visibility', { layerId: 'recent-imagery', enabled: false });
// (G5) Bulk place search without an operator Overpass: a plain refusal.
const configured = await page.evaluate(() => fetch('/api/overpass/status').then((res) => res.json()).catch(() => null));
r = await run('osm_query', { what: 'hospitals' });
if (configured?.configured) {
skipped('geometry: place search without an operator Overpass is refused', 'this server has OVERPASS_UPSTREAMS');
} else {
report(r?.ok === false && r?.code === 'OVERPASS_NOT_CONFIGURED',
'geometry: place search without an operator Overpass is refused', `code=${r?.code} error=${r?.error}`);
}
const nominatimUsed = nominatimCount() - nominatimStart;
const overpass = overpassRequests.slice(overpassStart);
console.log(` geometry timings: ${JSON.stringify(timings)}; public Nominatim requests: ${nominatimUsed}`);
report(overpass.length === 0, 'geometry: no Overpass request', overpass.slice(0, 3).join(' | ') || 'none');
report(nominatimUsed <= 15, 'geometry: public Nominatim requests within budget (15)', `used=${nominatimUsed}`);
await run('clear_annotations', {});
}
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
function nominatimCount() {
try {
-333
View File
@@ -1,333 +0,0 @@
#!/usr/bin/env node
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
]),
],
protocolTimeout: 120_000,
});
const errors = [];
try {
const page = await browser.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1200, height: 800 });
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView.viewer.scene.screenSpaceCameraController
.enableInputs &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
const renderer = await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
const { readCameraTargetFrame } =
await import('/src/ui/cameraOrientationControls.js');
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight();
viewer.trackedEntity = undefined;
// Explicit real renders make each gesture independent of host frame rate.
viewer.useDefaultRenderLoop = false;
const controller = viewer.scene.screenSpaceCameraController;
controller.inertiaZoom = 0;
controller.inertiaSpin = 0;
const original = viewer.camera.setView.bind(viewer.camera);
const qa = {
viewer,
guardWrites: 0,
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
history: [],
frame() {
viewer.scene.requestRender();
viewer.render();
qa.history.push({
height: viewer.camera._positionCartographic.height,
guardWrites: qa.guardWrites,
position: { ...viewer.camera.position },
});
if (qa.history.length > 80) qa.history.shift();
},
pose() {
return {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
maxPitch: maxPitchForHeight(
viewer.camera.positionCartographic.height,
),
guardWrites: qa.guardWrites,
};
},
reset(height = 1_000_000, pitch = -35) {
viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
original({
destination: C.Cartesian3.fromDegrees(0, 0, height),
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
});
qa.frame();
qa.guardWrites = 0;
},
};
viewer.camera.setView = (options) => {
// Cesium itself also calls setView while processing touch movement.
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
qa.guardWrites++;
return original(options);
};
window.__zoomGuardQa = qa;
const gl = viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
});
console.log(`[INFO] WebGL renderer: ${renderer}`);
const frame = (count = 1) =>
page.evaluate(async (n) => {
for (let i = 0; i < n; i++) {
window.__zoomGuardQa.frame();
await new Promise((resolve) => setTimeout(resolve, 0));
}
return window.__zoomGuardQa.pose();
}, count);
const reset = (height, pitch) =>
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
const session = await page.createCDPSession();
for (const viewport of [
{ width: 1200, height: 800 },
{ width: 390, height: 844 },
]) {
await page.setViewport(viewport);
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
const x = Math.round(viewport.width / 2);
const y = Math.round(viewport.height / 2);
await reset(1_000_000, -25);
await page.mouse.move(x, y);
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 2400,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
const first = await frame();
console.log(`[INFO] ${viewport.width}px wheel delivered`);
const settled = await frame(40);
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
assert.ok(
settled.pitch <= settled.maxPitch + 1e-6,
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
);
for (let i = 0; i < 12; i++) {
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 2400,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
await frame();
}
const ceiling = await frame(40);
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
assert.ok(
ceiling.height > 39_000_000,
'exercise the ceiling, not a low orbit',
);
assert.ok(
ceiling.pitch <= ceiling.maxPitch + 1e-6,
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
);
console.log(
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`zoom-ceiling-${viewport.width}.png`,
),
});
}
await reset();
await page.mouse.move(x, y);
await page.keyboard.down('Control');
await page.mouse.down({ button: 'right' });
for (let i = 1; i <= 6; i++) {
await page.mouse.move(x, y + i * 3);
await frame();
}
await page.mouse.up({ button: 'right' });
await page.keyboard.up('Control');
const tilted = await frame(3);
assert.equal(
tilted.guardWrites,
0,
'Ctrl+right tilt must not trigger guard',
);
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
await reset();
const touches = (dy) => [
{ x: x - 60, y: y + dy, id: 1 },
{ x: x + 60, y: y + dy, id: 2 },
];
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: touches(0),
});
await frame();
for (let i = 1; i <= 6; i++) {
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: touches(i * 3),
});
await frame();
}
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
const translated = await frame(3);
assert.equal(
translated.guardWrites,
0,
'parallel two-finger tilt must not trigger guard',
);
assert.ok(
Math.abs(translated.height - 1_000_000) > 1,
'touch tilt must move camera',
);
console.log(
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
);
await reset(1_000_000, -35);
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 120,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
const beforeTilt = await frame();
assert.ok(
beforeTilt.guardWrites > 0,
'wheel correction must be pending before UI tilt',
);
const targetPitchBeforeClick = await page.evaluate(() =>
window.__zoomGuardQa.targetPitch(),
);
assert.ok(
Number.isFinite(targetPitchBeforeClick),
'UI tilt fixture must have a ground target under the canvas center',
);
// Programmatic fixture resets precede moveEnd, so the button's published
// aria state may still describe the previous view. The action toggles the
// current target frame: angles shallower than -60 degrees are tilted.
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
await page.click('#tilt-map-view');
const uiTilt = await page.evaluate(
async (desiredDegrees) => {
const qa = window.__zoomGuardQa;
const desired = (desiredDegrees * Math.PI) / 180;
const started = performance.now();
const deadline = started + 3000;
while (performance.now() < deadline) {
qa.frame();
// The 650 ms animator can be numerically near its destination
// before it releases preUpdate. Finish its lifetime before resetting.
if (
performance.now() - started >= 700 &&
Math.abs(qa.targetPitch() - desired) < 1e-5
)
return { settled: true, guardWrites: qa.guardWrites };
await new Promise((resolve) => setTimeout(resolve, 16));
}
return {
settled: false,
guardWrites: qa.guardWrites,
pitch: qa.targetPitch(),
};
},
wasTilted ? -89 : -35,
);
assert.ok(
uiTilt.settled,
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
);
assert.equal(
uiTilt.guardWrites,
beforeTilt.guardWrites,
'UI navigation handoff must cancel pending zoom correction',
);
console.log(
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
);
await reset(1_000_000, -25);
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: touches(0),
});
await frame();
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: x - 35, y, id: 1 },
{ x: x + 35, y, id: 2 },
],
});
await frame();
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
const pinched = await frame(40);
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
}
assert.deepEqual(errors, [], 'no JavaScript page errors');
console.log('[PASS] no JavaScript page errors');
} finally {
await browser.close();
}
-59
View File
@@ -1,59 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
discoverUnitTestFiles,
UNIT_TEST_NODE_FLAGS,
} from './run-unit-tests.mjs';
/** Discover every layer/provider benchmark, including those added by other branches. */
export function discoverPerfTestFiles(root = process.cwd()) {
return discoverUnitTestFiles(root).filter((file) =>
file.endsWith('.perf.test.mjs'),
);
}
/** Shared by benchmarks so normal unit runs visibly skip timing assertions. */
export function perfBudgetTestOptions(env = process.env) {
return {
skip:
env.GEV_PERF_BUDGETS === '1'
? false
: 'Timing budgets are opt-in; run npm run test:perf (GEV_PERF_BUDGETS=1). Deterministic delta/write checks still run in the normal suite.',
};
}
/** Build a serial invocation so benchmarks do not compete for CPU time. */
export function perfTestArgs(files) {
// A single serial test process keeps benchmark files from competing with
// each other; the normal suite still discovers them and reports their skips.
return [
...UNIT_TEST_NODE_FLAGS,
'--test',
'--test-concurrency=1',
'--test-isolation=none',
...files,
];
}
/** Run discovered benchmarks with wall-clock budgets enabled. */
export function runPerfTests({
root = process.cwd(),
env = process.env,
run = spawnSync,
} = {}) {
const files = discoverPerfTestFiles(root);
if (!files.length) throw new Error('No *.perf.test.mjs benchmarks found');
const result = run(process.execPath, perfTestArgs(files), {
cwd: root,
stdio: 'inherit',
env: { ...env, GEV_PERF_BUDGETS: '1' },
});
if (result.error) throw result.error;
return result.status ?? 1;
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) process.exitCode = runPerfTests();
+9 -26
View File
@@ -94,7 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -2698,18 +2698,16 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle frame
// within 500 ms of forcing moveEnd and the first reveal frame within
// 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2771,23 +2769,7 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
// landed where the 1 s idle refresh could already have repaired it
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
// observation gets one more attempt; one that measured a wrong angle —
// a wrong first reveal, or a wrong settle frame before a reveal
// timeout — never does.
const observed = await observeArrivalRotationWithRetry({
async prepare() {
// Freshen both layers' rotation passes right before the probe, so
// the settle frame is measured against a pass that just ran rather
// than one left over from the previous probe — with the camera
// idle, a layer otherwise runs a pass only every
// ROTATION_REFRESH_MS.
v.camera.moveEnd.raiseEvent();
await nextFrames(1);
},
const observed = await observeArrivalRotation({
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
@@ -2808,6 +2790,7 @@ async function main() {
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2817,8 +2800,8 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+15 -50
View File
@@ -90,11 +90,8 @@ export const CORE_PHRASES = Object.freeze([
// — annotations —
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
// A named boundary outlines through resolve_area (a reusable handle the
// user can count or search inside); annotate_map is for marks.
{ phrase: 'Outline the state of Texas', expect: 'resolve_area', args: { draw: true }, typedArgs: [where('resolve_area', (a) => a.draw === true)] },
// A lake is a water footprint as much as a boundary: either tool draws it.
{ phrase: 'Outline Lady Bird Lake', expect: { oneOf: ['annotate_map', 'resolve_area'] }, typedArgs: [(c) => c.name === 'resolve_area' || (c.name === 'annotate_map' && (c.args?.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline the state of Texas', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline Lady Bird Lake', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
{ phrase: 'Clear the map', expect: 'clear_annotations' },
@@ -222,7 +219,7 @@ export const COVERAGE_PROBE = Object.freeze([
},
{
id: 'recent-imagery',
v2: { supported: true, correct: [[call('find_imagery')], [call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
utterance: 'Show recent satellite imagery of this area',
scene: 'Austin',
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
@@ -253,8 +250,13 @@ export const COVERAGE_PROBE = Object.freeze([
},
{
id: 'fires-in-drawn-area',
// The drawn-area scope exists: a count must be scoped to it.
v2: { supported: true, correct: [[where('analyst_query', (a) => (a.layers || []).includes('local-firms') && ['drawn', 'annotation', 'area'].includes(a.scope?.kind))]] },
// There is no drawn-area scope yet; the prompt says to say so. Any count
// presented as inside the drawn area is a faked capability.
v2: {
supported: false,
correct: [],
hallucinated: [where('analyst_query', (a) => (a.layers || []).includes('local-firms'))],
},
utterance: 'Count the fires inside the area I drew',
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
@@ -546,42 +548,14 @@ export function dialogueResult(dialogue, toolName, turn) {
return byTurn[turn] ?? null;
}
// ── New capabilities (voice geometry, point-and-ask, layer reach) ─────────
// ── New capabilities (point-and-ask, layer reach) ─────────────────────────
//
// ROUTING_PHRASES is the qa-voice-routing table (core + geometry + place
// search), so that harness runs exactly its own table. The benchmark runs
// CAPABILITY_EXTRAS as the separate `capability` suite so the core suite
// stays comparable across tool versions.
/** Areas, drawn-area counts and imagery: offered in every session. */
export const GEOMETRY_PHRASES = Object.freeze([
{ phrase: 'Outline Bagmati province', expect: 'resolve_area', args: { query: 'bagmati', draw: true } },
{ phrase: 'Outline Kathmandu district', expect: 'resolve_area', args: { query: 'kathmandu' } },
{ phrase: 'Show me the border of Punjab', expect: 'resolve_area', args: { query: 'punjab' } },
{ phrase: 'Draw an area around the Ferry Building in San Francisco', expect: 'resolve_area', args: { around: true } },
{ phrase: 'Count the fires inside Bagmati province', expect: { oneOf: ['analyst_query', 'resolve_area'] } },
{ phrase: 'How many flights are inside the area I drew?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'drawn')] },
{ phrase: 'How many ships are inside the area you just outlined?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'annotation' || a.scope?.kind === 'area')] },
{ phrase: 'Show me recent satellite imagery of this area', expect: 'find_imagery' },
{ phrase: 'Find the latest cloud-free satellite image here', expect: 'find_imagery', typedArgs: [where('find_imagery', (a) => typeof a.maxCloudPct === 'number' && a.maxCloudPct <= 20)] },
{ phrase: 'Get me recent imagery of Kathmandu', expect: { oneOf: ['find_imagery', 'resolve_area'] } },
]);
/**
* Bulk place search: osm_query is offered only when the server has an
* operator Overpass, so the routing harness skips these otherwise.
*/
export const OVERPASS_PHRASES = Object.freeze([
{ phrase: 'Show me all the hospitals in Kathmandu', expect: { oneOf: ['resolve_area', 'osm_query'] }, requires: ['osm_query'] },
{ phrase: 'Find the police stations in view', expect: 'osm_query', args: { what: 'police' }, requires: ['osm_query'] },
]);
// ROUTING_PHRASES is the qa-voice-routing table, so that harness runs exactly
// its own table. The benchmark runs CAPABILITY_EXTRAS as the separate
// `capability` suite so the core suite stays comparable across tool versions.
/** The full qa-voice-routing table. */
export const ROUTING_PHRASES = Object.freeze([
...CORE_PHRASES,
...GEOMETRY_PHRASES,
...OVERPASS_PHRASES,
]);
export const ROUTING_PHRASES = CORE_PHRASES;
// Pointer context items in the app's own format (src/voice/pointerContext.js
// output), sent as an inert system item just before the user's words.
@@ -595,7 +569,6 @@ const GROUND_POINTER = {
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
};
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
const bagmati = { ok: true, areaId: 'area-1', name: 'Bagmati Province', level: 'admin1', drawn: true, say: 'Bagmati Province — about 20,000 km²' };
const flightsInView = {
ok: true,
count: 3,
@@ -620,14 +593,6 @@ export const CAPABILITY_EXTRAS = Object.freeze([
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
{ phrase: 'Show recent satellite imagery here', context: GROUND_POINTER, expect: { oneOf: ['find_imagery', 'set_layer_visibility'] }, typedArgs: [(c) => c.name === 'find_imagery' || (c.name === 'set_layer_visibility' && c.args?.layerId === 'recent-imagery')] },
{
id: 'bagmati-then-fires',
turns: ['Outline Bagmati province', 'Count the fires inside it'],
results: { resolve_area: [bagmati] },
first: { expect: 'resolve_area' },
second: { expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('local-firms') && a.scope?.kind === 'area' && a.scope?.areaId === 'area-1')] },
},
{
id: 'flights-then-second',
turns: ['How many flights are in view?', 'Track the second one'],
-181
View File
@@ -1,181 +0,0 @@
import { normalizeAcartiaSightings } from '../../src/layers/wildlifeSightings/records.js';
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { providerUserAgent } from './common/userAgent.js';
const API_URL = 'https://acartia.io/api/v1/sightings/current';
// All views share the provider's seven-day snapshot.
const TTL_MS = 600_000;
const STALE_MAX_MS = 24 * 60 * 60_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const TIMEOUT_MS = 15_000;
const COOLDOWN_MS = 60_000;
const COOLDOWN_MIN_MS = 5000;
const COOLDOWN_MAX_MS = 60 * 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Bound upstream Retry-After seconds or dates to five seconds–one hour. */
export function acartiaRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, delay));
}
return COOLDOWN_MS;
}
/** Salish Sea coverage. Precise coordinates and comments never enter cache. */
export function acartiaProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cache = new Map(),
warn = (...args) => console.warn(...args),
} = {}) {
const inFlight = new Map();
const allow = makeRateLimiter({
windowMs: 60_000,
max: 60,
globalMax: 600,
now,
});
let cooldownUntil = 0;
let cooldownStatus = 429;
let warnedClamp = false;
const retrySeconds = () =>
Math.max(1, Math.ceil((cooldownUntil - now()) / 1000));
const unavailable = (status = 502) =>
Object.assign(new Error('acartia_unavailable'), {
status,
retryAfterS: retrySeconds(),
});
function coolDown(status, delay) {
const until = now() + delay;
if (until > cooldownUntil) {
cooldownUntil = until;
cooldownStatus = status;
} else if (status === 429 && now() < cooldownUntil) cooldownStatus = 429;
}
async function load() {
if (now() < cooldownUntil) throw unavailable(cooldownStatus);
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
const response = await fetchImpl(API_URL, {
signal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = acartiaRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
if (delay === COOLDOWN_MAX_MS && !warnedClamp) {
warnedClamp = true;
warn('[acartia-proxy] upstream cooldown capped at one hour');
}
coolDown(429, delay);
throw unavailable(429);
}
throw unavailable();
}
// This endpoint documents a JSON array, not a results/data envelope.
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
signal,
);
const rows = normalizeAcartiaSightings(payload, now());
// Only a non-array is malformed. A quiet feed, or one whose rows all fall
// outside coverage or the species checks, is a valid empty snapshot.
if (!rows) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
} catch (error) {
if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS);
warn('[acartia-proxy] upstream refresh failed');
throw unavailable(error.status === 429 ? 429 : 502);
}
}
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
async function acquire() {
const previous = cache.get('sightings');
if (age(previous) < TTL_MS) return { value: previous.value, stale: false };
if (age(previous) > STALE_MAX_MS) cache.delete('sightings');
try {
const { promise } = coalesceProxyRequest(
inFlight,
'sightings',
async () => {
const value = await load();
cache.set('sightings', { value, savedAt: now() });
return value;
},
);
return { value: await promise, stale: false };
} catch (error) {
if (age(previous) <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, { stale = false, retryAfterS } = {}) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(retryAfterS ? { 'Retry-After': String(retryAfterS) } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const pathname = (req.url || '/').split('?')[0];
if (pathname !== '/sightings') return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req)))
return json(429, { error: 'rate_limited' }, { retryAfterS: 60 });
try {
const { value, stale } = await acquire();
return json(200, stale ? { ...value, stale: true } : value, { stale });
} catch (error) {
return json(
error.status === 429 ? 429 : 502,
{ error: 'acartia_unavailable' },
{ retryAfterS: error.retryAfterS },
);
}
}
return {
name: 'acartia',
configureServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
};
}
-419
View File
@@ -1,419 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { acartiaProxy, acartiaRetryCooldownMs } from './acartia.js';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const sighting = (id = 'synthetic-1') => ({
entry_id: id,
latitude: 48.12345,
longitude: -123.12345,
created: '2026-11-04 17:00:00',
type: 'Orca',
no_sighted: '5',
photo_url: 'https://provider.example/photos/synthetic.jpg',
data_source_name: 'Synthetic Whale Network',
data_source_entity: 'Community reports',
trusted: true,
data_source_comments: '[email protected] +1-555-000-0000',
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = acartiaProxy({ now: () => NOW, warn() {}, ...options });
assert.equal(plugin.name, 'acartia');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/acartia');
handler = callback;
},
},
});
return async (
url = '/sightings',
{ method = 'GET', peer = 'local', headers = {} } = {},
) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
test('cache stores only supported marine mammals inside the coverage box', async () => {
const cache = new Map();
const request = install({
cache,
fetchImpl: async () =>
Response.json([
sighting('orca'),
{ ...sighting('seal'), type: 'Harbor seal' },
{ ...sighting('unknown'), type: 'Unknown' },
{ ...sighting('fish'), type: 'Whale shark' },
{ ...sighting('outside'), latitude: 37.8 },
{ ...sighting('rounded-edge'), latitude: 45.49999 },
]),
});
const result = await request();
assert.equal(result.status, 200);
for (const rows of [result.body.rows, cache.get('sightings').value.rows]) {
assert.deepEqual(
rows.map((row) => [row.stableId, row.cetacean]),
[
['acartia:orca', true],
['acartia:seal', false],
],
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: documented array is normalized and coarsened before caching`, async () => {
const cache = new Map();
let calls = 0;
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls += 1;
assert.equal(url, 'https://acartia.io/api/v1/sightings/current');
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json([sighting()]);
},
},
hook,
);
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const result = await request(
'/sightings?url=https://attacker.example&latitude=1',
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.rows[0].stableId, 'acartia:synthetic-1');
assert.equal(result.body.rows[0].lat, 48.12);
assert.equal(result.body.rows[0].lon, -123.12);
assert.equal(result.body.rows[0].provider.name, 'Synthetic Whale Network');
const retained = cache.get('sightings');
assert.equal(retained.value.rows[0].lat, 48.12);
assert.equal(retained.value.rows[0].lon, -123.12);
for (const value of [result.body, [...cache.values()]])
assert.doesNotMatch(
JSON.stringify(value),
/private-observer|555|data_source_comments|48\.12345|123\.12345/,
);
assert.equal(calls, 1);
});
}
test('gate, method and fixed route run before cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 65; i += 1) {
assert.equal(
(
await request('/sightings', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
}
const method = await request('/sightings', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/sightings/extra']) {
const result = await request(route);
assert.equal(result.status, 404);
assert.equal(result.body.error, 'unknown_route');
}
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const sameOrigin = await request('/sightings', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(sameOrigin.status, 200);
});
test('request 61 is locally limited even with a fresh cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 60; i += 1) assert.equal((await request()).status, 200);
const limited = await request();
assert.equal(limited.status, 429);
assert.equal(limited.body.error, 'rate_limited');
assert.equal(limited.headers['Retry-After'], '60');
assert.equal(calls, 1);
});
test('one global snapshot single-flights and stays fresh for ten minutes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json([sighting()]);
},
});
const first = request();
const concurrent = request();
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
assert.deepEqual((await first).body, (await concurrent).body);
at += 599_999;
await request();
assert.equal(calls, 1);
at += 1;
await request();
assert.equal(calls, 2);
});
test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => {
for (const quiet of [
[],
[null],
[{ ...sighting(), data_source_name: '' }],
[{ ...sighting(), latitude: 37.8 }],
[{ ...sighting(), type: 'Unknown' }],
]) {
let at = NOW;
let calls = 0;
let empty = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(empty ? quiet : [sighting()]);
},
});
assert.equal((await request()).body.rows.length, 1);
empty = true;
at += 600_001;
const result = await request();
assert.equal(result.status, 200);
assert.deepEqual(result.body, { fetchedAt: at, rows: [] });
assert.equal(result.headers['X-Data-Stale'], undefined);
assert.equal(result.headers['Retry-After'], undefined);
assert.deepEqual(cache.get('sightings').value.rows, []);
// The quiet feed keeps the normal ten-minute cadence, not a failure retry.
at += 599_999;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
at += 1;
await request();
assert.equal(calls, 3);
const cold = install({ fetchImpl: async () => Response.json(quiet) });
const coldResult = await cold();
assert.equal(coldResult.status, 200);
assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] });
}
});
test('an invented wrapped snapshot never replaces the cache', async () => {
const bad = { results: [sighting()] };
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(fail ? bad : [sighting()]),
});
const fresh = await request();
const retained = cache.get('sightings');
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
assert.equal(cache.get('sightings'), retained);
const coldCache = new Map();
const cold = install({
cache: coldCache,
fetchImpl: async () => Response.json(bad),
});
const result = await cold();
assert.equal(result.status, 502);
assert.equal(result.body.error, 'acartia_unavailable');
assert.equal(result.headers['Retry-After'], '15');
assert.equal(coldCache.size, 0);
});
test('failure serves stale for at most 24 hours and never reports a cold success', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () => {
if (fail) throw new Error('private upstream detail');
return Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 23 * 60 * 60_000 + 59 * 60_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'acartia_unavailable' });
});
test('upstream 429 honors Retry-After, caps it, and spends no requests while cooling down', async () => {
assert.equal(acartiaRetryCooldownMs('2', NOW), 5000);
assert.equal(acartiaRetryCooldownMs('999999', NOW), 3_600_000);
assert.equal(
acartiaRetryCooldownMs(new Date(NOW + 120_000).toUTCString(), NOW),
120_000,
);
assert.equal(acartiaRetryCooldownMs(null, NOW), 60_000);
let at = NOW;
let calls = 0;
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.body.error, 'acartia_unavailable');
assert.equal(first.headers['Retry-After'], '3600');
at += 5000;
const cooling = await request();
assert.equal(cooling.headers['Retry-After'], '3595');
assert.equal(calls, 1);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
at += 3_600_000;
await request();
assert.equal(calls, 2);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
});
test('upstream 429 also serves a prior snapshot stale', async () => {
let at = NOW;
let fail = false;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return fail
? new Response('limit', { status: 429 })
: Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 1000;
assert.equal((await request()).body.stale, true);
assert.equal(calls, 2);
});
test('bad JSON, 5xx and capped bodies start a 15 second failure cooldown', async () => {
for (const bad of [
() => new Response('{malformed'),
() => new Response('private upstream message', { status: 503 }),
() =>
new Response('{}', {
headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json([sighting()]);
},
});
const first = await request();
assert.equal(first.status, 502);
assert.deepEqual(first.body, { error: 'acartia_unavailable' });
assert.equal(first.headers['Retry-After'], '15');
at += 14_999;
assert.equal((await request()).headers['Retry-After'], '1');
assert.equal(calls, 1);
at += 1;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
-6
View File
@@ -28,7 +28,6 @@ import {
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
loadHoustonSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js';
@@ -167,11 +166,6 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
{
name: 'houston',
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
load: loadHoustonSourcesFromOpenData,
},
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
-31
View File
@@ -443,37 +443,6 @@ export const NYC_ANCHORS = [
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
];
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
* cameras with a numeric id). Frames are stills synthesized from that id on
* the city's snapshot host — never taken from the payload. The catalog is
* served as `application/javascript`; asking for `application/json` gets HTTP
* 406, so the loader accepts any media type. */
export const HOUSTON_CAMERAS_URL =
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
/** The only origin Houston camera frames may come from. The catalog's `url`
* is a `showWin(...)` viewer helper and is ignored; the registered path is
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
export const HOUSTON_IMAGE_URL = (id) =>
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
* different (non-snapshot) camera class in the same list. */
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Ground-elevation prior in metres. The TranStar payload carries none, and
* Houston sits a few metres above sea level. */
export const HOUSTON_GROUND_ELEVATION_M = 15;
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
export const HOUSTON_ANCHORS = [
{ lat: 29.7604, lon: -95.3698 }, // Downtown
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
{ lat: 29.9902, lon: -95.3368 }, // IAH
{ lat: 29.3013, lon: -94.7977 }, // Galveston
];
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
* own camera list with coordinates, one request for the whole motorway
-12
View File
@@ -410,18 +410,6 @@ export function isLikelyHongKongCoordinate(lat, lon) {
);
}
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
* TranStar partners (Dallas, Columbus) are out of this city pack. */
export function isLikelyHoustonCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 29.2 &&
lat <= 30.35 &&
lon >= -96.05 &&
lon <= -94.7
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
-167
View File
@@ -127,13 +127,6 @@ import {
A22_MAX_CATALOG_BYTES,
A22_GROUND_ELEVATION_BY_KM,
A22_DEFAULT_GROUND_ELEVATION_M,
HOUSTON_CAMERAS_URL,
HOUSTON_IMAGE_URL,
HOUSTON_CAMERA_ID_RE,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_MAX_CATALOG_BYTES,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -154,7 +147,6 @@ import {
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHoustonCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
@@ -3779,162 +3771,3 @@ export async function loadA22SourcesFromOpenDataHub() {
return [];
}
}
/**
* Label for one Houston TranStar camera: the interchange an operator
* recognises ("10 EAST @ SAN JACINTO").
*
* @param {object} record - Raw TranStar camera row.
* @param {string} cameraId - Derived stable id.
* @returns {string}
*/
export function houstonCameraName(record, cameraId) {
const name = String(record?.location ?? '').trim();
if (name) return name;
const short = String(cameraId || '').replace(/^houston-/, '');
return short ? `Houston Camera ${short}` : 'Houston Camera';
}
/**
* TranStar `dir` (the camera's side of the freeway) -> the travel direction
* of the nearest lanes, as TranStar's map script `translateCameraDirection`
* reads it. Only these four values are mapped; anything else is unknown.
*/
const HOUSTON_TRAVEL_HEADING_BY_SIDE = Object.freeze({
south: 90,
north: 270,
west: 180,
east: 0,
});
/**
* One Houston TranStar camera row -> one catalog source, or null.
*
* Frame URLs are synthesized from a validated numeric id on the pinned
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
* TranStar's `dir` is the side of the freeway the camera sits on, not the
* way it faces: TranStar's own map labels it "Traffic closest to the camera
* is traveling …" (South→Eastbound, North→Westbound, West→Southbound,
* East→Northbound). That travel direction is only a low-confidence prior
* for the view, so the heading stays estimated with the low-confidence
* pose; `None`, blank or unknown values fall back to the shared id-hash
* heading.
*
* @param {object} record - Raw TranStar camera row.
* @returns {?object}
*/
export function houstonCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const lat = toFiniteNumber(record.lat);
const lon = toFiniteNumber(record.lng);
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
const rawId = String(record.id ?? '').trim();
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
const cameraId = `houston-${rawId}`;
const imageUrl = HOUSTON_IMAGE_URL(rawId);
const name = houstonCameraName(record, cameraId);
const travelHeading =
HOUSTON_TRAVEL_HEADING_BY_SIDE[
String(record.dir ?? '')
.trim()
.toLowerCase()
];
return {
id: cameraId,
name,
city: 'Houston',
cityId: 'houston',
provider: 'Houston TranStar',
lat,
lon,
headingDeg: Number.isFinite(travelHeading)
? travelHeading
: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'houston-transtar',
license:
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
* a validated numeric id rather than from the payload URL. The catalog host
* answers `Accept: application/json` with HTTP 406, so this request accepts
* any media type.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHoustonSourcesFromOpenData() {
try {
const endpoint =
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: '*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Houston catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Houston camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
HOUSTON_MAX_CATALOG_BYTES,
);
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = houstonCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_HOUSTON_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
console.log(
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Houston camera download error:',
error?.message || error,
);
return [];
}
}
-137
View File
@@ -1,137 +0,0 @@
import path from 'node:path';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
/**
* A bounded two-level cache for JSON answers: memory (entry count) and disk
* (entry count, total bytes, age). Every memory insertion goes through one
* function, so a disk hit is bounded like a fresh answer; disk writes are
* followed by a prune that removes expired files first, then the oldest,
* until both quotas hold.
*
* @param {object} options
* @param {string|null} options.dir Disk directory; null keeps memory only.
* @param {number} options.ttlMs Age past which an entry is not served.
* @param {number} [options.maxMemoryEntries]
* @param {number} [options.maxDiskEntries]
* @param {number} [options.maxDiskBytes]
* @param {() => number} [options.now]
*/
export function createBoundedCache({
dir,
ttlMs,
maxMemoryEntries = 60,
maxDiskEntries = 400,
maxDiskBytes = 256 * 1024 * 1024,
now = () => Date.now(),
}) {
const memory = new Map();
let pruning = null;
let prunePending = false;
const fileFor = (key) =>
path.join(dir, `${createHash('sha1').update(key).digest('hex')}.json`);
function remember(key, entry) {
memory.delete(key);
memory.set(key, entry);
while (memory.size > maxMemoryEntries)
memory.delete(memory.keys().next().value);
}
async function prune() {
if (!dir) return;
let names;
try {
names = (await fsp.readdir(dir)).filter((name) => name.endsWith('.json'));
} catch {
return;
}
const files = [];
for (const name of names) {
const file = path.join(dir, name);
try {
const stat = await fsp.stat(file);
files.push({ file, size: stat.size, mtime: stat.mtimeMs });
} catch {
/* removed meanwhile */
}
}
const cutoff = now() - ttlMs;
const keep = [];
for (const entry of files) {
if (entry.mtime < cutoff) await fsp.rm(entry.file, { force: true });
else keep.push(entry);
}
keep.sort((a, b) => a.mtime - b.mtime);
let bytes = keep.reduce((sum, entry) => sum + entry.size, 0);
while (
keep.length &&
(keep.length > maxDiskEntries || bytes > maxDiskBytes)
) {
const oldest = keep.shift();
bytes -= oldest.size;
await fsp.rm(oldest.file, { force: true });
}
}
/** Run one prune at a time; a request during a prune schedules one more. */
function schedulePrune() {
if (pruning) {
prunePending = true;
return pruning;
}
pruning = prune()
.catch(() => {})
.finally(() => {
pruning = null;
if (prunePending) {
prunePending = false;
schedulePrune();
}
});
return pruning;
}
return {
/** The cached value, or undefined. A disk hit is re-inserted into memory. */
async get(key) {
const held = memory.get(key);
if (held && now() - held.cachedAt <= ttlMs) return held.value;
if (held) memory.delete(key);
if (!dir) return undefined;
try {
const entry = JSON.parse(await fsp.readFile(fileFor(key), 'utf8'));
if (!Number.isFinite(entry?.cachedAt) || now() - entry.cachedAt > ttlMs)
return undefined;
remember(key, entry);
return entry.value;
} catch {
return undefined;
}
},
/** Store a value in memory and, when a directory is set, on disk. */
async set(key, value) {
const entry = { value, cachedAt: now() };
remember(key, entry);
if (!dir) return;
try {
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(fileFor(key), JSON.stringify(entry));
} catch {
return;
}
// In the background: an answer never waits on a 400-file directory scan.
// `prune()` returns the pending pass for callers that need it done.
schedulePrune();
},
/** Run a prune and wait until no pass is running or queued. */
async prune() {
schedulePrune();
while (pruning) await pruning;
},
get memorySize() {
return memory.size;
},
};
}
-44
View File
@@ -1,4 +1,3 @@
import { isIPv6 } from 'node:net';
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
@@ -61,46 +60,3 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local');
}
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
/**
* Whether a socket peer address is this machine: IPv4 loopback in any of its
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
* @param {string} address - A `clientKey` value.
* @returns {boolean}
*/
export function isLoopbackPeer(address) {
const raw = String(address || '');
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
return v4.startsWith('127.') || raw === '::1';
}
/**
* Rate-limit key that survives address rotation, for limiters whose global
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
* address at will inside its /64, so keying by the full address lets a single
* host mint per-peer quota until the global window closes for everyone else.
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
* address folds to its IPv4 form so one host cannot hold two buckets. Any
* other value (IPv4, or the `local` fallback) passes through unchanged.
* @param {import('node:http').IncomingMessage} req
* @returns {string}
*/
export function clientNetworkKey(req) {
const address = clientKey(req);
const mapped = IPV4_MAPPED_RE.exec(address);
if (mapped) return mapped[1];
const bare = address.replace(/%.*$/, ''); // link-local zone id
if (!isIPv6(bare)) return address;
const [head, tail = ''] = bare.split('::');
const headParts = head ? head.split(':') : [];
const tailParts = tail ? tail.split(':') : [];
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
return `${hextets
.slice(0, 4)
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
.join(':')}::/64`;
}
@@ -1,37 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
const req = (remoteAddress) => ({ socket: { remoteAddress } });
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
assert.equal(
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
'2001:db8:1:2::/64',
);
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req(undefined)), 'local');
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
});
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
assert.equal(isLoopbackPeer(address), true, address);
}
for (const address of [
'10.0.0.1',
'::ffff:10.0.0.1',
'2001:db8::1',
'::2',
'local',
'',
undefined,
]) {
assert.equal(isLoopbackPeer(address), false, String(address));
}
});
+1 -1
View File
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
-221
View File
@@ -1,221 +0,0 @@
import { readRequestBody } from './request.js';
import {
clientKey,
makeCostRateLimiter,
resolvePerMinuteCap,
} from './rate-limit.js';
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
import { isGeminiRequestOriginAllowed } from '../gemini/request.js';
import { parseUsage } from './textTools.js';
const MAX_BYTES = 512 * 1024;
const FAILURE = 'Could not connect. Try again.';
// A rejected key is not retried upstream for this long, unless the key changes.
const REJECTED_KEY_COOLDOWN_MS = 60_000;
// What an upstream failure says about the key or the account. Gemini answers
// an invalid key with 400 and an ErrorInfo reason, not 401; OpenAI answers an
// account without credit with 429 insufficient_quota, which waiting won't fix.
// A 403 is the key's project, region or permissions, so it cools down too but
// does not blame the key. Only small error bodies are read.
async function upstreamRefusal(response, signal) {
const { status } = response;
if (status === 401) return 'API_KEY_REJECTED';
if (status === 403) return 'ACCESS_DENIED';
if (![400, 429].includes(status)) return null;
let error;
try {
error = (await readResponseJsonCapped(response, 16 * 1024, signal))?.error;
} catch {
return null;
}
if (
status === 400 &&
Array.isArray(error?.details) &&
error.details.some((detail) => detail?.reason === 'API_KEY_INVALID')
)
return 'API_KEY_REJECTED';
if (status === 429 && error?.code === 'insufficient_quota')
return 'QUOTA_EXCEEDED';
return null;
}
// Pass through only a delay in whole seconds, capped at an hour.
function upstreamRetryAfter(response) {
const value = String(response.headers?.get?.('retry-after') ?? '').trim();
if (!/^\d{1,6}$/.test(value)) return null;
return String(Math.min(Math.max(Number(value), 1), 3600));
}
export function createTextChatHandler({
provider,
prepare,
parse,
fetchImpl = (...args) => fetch(...args),
resolveApiKey,
resolveHost = () => process.env.HOST,
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
resolveRateLimit = () =>
process.env[`GEV_RATELIMIT_${provider.toUpperCase()}_CHAT_PER_MIN`],
// An unset chat throttle follows the provider's own cost throttle.
resolveProviderRateLimit = () => undefined,
providerPerMin = 30,
// Returns { error, code } when the route must refuse before any key is used.
refuse = () => null,
now = Date.now,
timeoutMs = 60000,
} = {}) {
let limiter;
let rejected = null;
return async (req, res) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (status, value, code) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.statusCode = status;
if (status >= 400) res.setHeader('Connection', 'close');
res.end(
JSON.stringify(
typeof value === 'string'
? { error: value, ...(code ? { code } : {}) }
: value,
),
);
};
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return reply(405, 'Method not allowed');
}
const lifetime = new AbortController();
const disconnect = () => {
if (!res.writableEnded) lifetime.abort();
};
req.once('aborted', disconnect);
res.once('close', disconnect);
const timer = setTimeout(() => lifetime.abort(), timeoutMs);
timer.unref?.();
let response;
let usage = null;
try {
if (
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
)
return reply(403, FAILURE);
const refusal = refuse();
if (refusal) return reply(503, refusal.error, refusal.code);
const apiKey = String(resolveApiKey() || '').trim();
if (!apiKey)
return reply(
503,
'Add an API key in Provider Settings',
'API_KEY_MISSING',
);
if (rejected && (rejected.key !== apiKey || now() >= rejected.until))
rejected = null;
if (rejected) {
res.setHeader(
'Retry-After',
String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))),
);
return reply(502, FAILURE, rejected.code);
}
if (limiter === undefined)
limiter = makeCostRateLimiter(
resolveRateLimit(),
resolvePerMinuteCap(resolveProviderRateLimit(), providerPerMin),
);
if (limiter && !limiter(clientKey(req))) {
res.setHeader('Retry-After', '60');
return reply(429, FAILURE, 'RATE_LIMITED');
}
if (
!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')
)
return reply(415, FAILURE);
if (Number(req.headers['content-length']) > MAX_BYTES)
return reply(413, FAILURE);
const raw = await readRequestBody(req, MAX_BYTES, lifetime.signal);
let input;
try {
input = JSON.parse(raw);
} catch {
return reply(400, FAILURE);
}
const prepared = prepare(input);
if (!prepared) return reply(400, FAILURE);
if (prepared.errorStatus) return reply(prepared.errorStatus, FAILURE);
const { endpoint, body, headers } = prepared;
lifetime.signal.throwIfAborted();
const acquisition = Promise.resolve(
fetchImpl(endpoint, {
method: 'POST',
redirect: 'error',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json', ...headers(apiKey) },
body: JSON.stringify(body),
}),
);
void acquisition.then(
(late) => {
response = late;
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
},
() => {},
);
response = await waitForSignal(acquisition, lifetime.signal);
lifetime.signal.throwIfAborted();
if (response.redirected || (response.url && response.url !== endpoint))
throw new Error('Unexpected destination');
if (!response.ok) {
const refusal = await upstreamRefusal(response, lifetime.signal);
if (refusal) {
void response.body?.cancel().catch(() => {});
rejected = {
key: apiKey,
code: refusal,
until: now() + REJECTED_KEY_COOLDOWN_MS,
};
return reply(502, FAILURE, refusal);
}
void response.body?.cancel().catch(() => {});
if (![429, 503].includes(response.status)) return reply(502, FAILURE);
const retryAfter = upstreamRetryAfter(response);
if (retryAfter) res.setHeader('Retry-After', retryAfter);
return reply(
response.status,
FAILURE,
response.status === 429 ? 'RATE_LIMITED' : undefined,
);
}
const data = await readResponseJsonCapped(
response,
MAX_BYTES,
lifetime.signal,
);
lifetime.signal.throwIfAborted();
usage = parseUsage(provider, data);
const result = parse(data);
if (!result || JSON.stringify(result).includes(apiKey))
return reply(502, { error: FAILURE, usage });
reply(200, { ...result, usage });
} catch (error) {
reply(
error?.code === 'BODY_TOO_LARGE'
? 413
: lifetime.signal.aborted
? 504
: 502,
{ error: FAILURE, usage },
);
} finally {
if (lifetime.signal.aborted)
void response?.body?.cancel().catch(() => {});
clearTimeout(timer);
req.off('aborted', disconnect);
res.off('close', disconnect);
}
};
}
@@ -1,32 +0,0 @@
import { realtimeDirectives } from '../openai/instructions.js';
// Voice wording in the shared directives and its written form. A test checks
// each phrase still occurs in the directives, so rewording one there fails
// rather than leaking spoken wording into chat.
export const TEXT_WORDING = Object.freeze([
['without speaking first', 'without writing a preamble'],
['before speaking', 'before confirming'],
['may first say ONE sentence', 'may first write ONE sentence'],
["speak the result's say line", "write the result's say line"],
['on the voice card: speak a caveat', 'in the tool result: include a caveat'],
['as you talk', 'as you write'],
['you can read aloud', 'you can report'],
]);
// Shared-manual adaptation follows Darren Dignam's GEV COMMAND approach (#976).
// Keep our Radio handoff and annotation outcome policies from the shared manual.
// `options.overpass` matches the tools offered, as for voice.
export function textInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options)
.map((line, index) => {
if (index === 0)
return "You are GEV Chat, a concise text assistant for a Cesium geospatial app called God's Eye View.";
if (index === 1)
return 'Have a natural written conversation. This is text-only chat; no microphone or generated audio is used.';
return TEXT_WORDING.reduce(
(text, [spoken, written]) => text.replace(spoken, written),
line,
);
})
.join('\n');
}
-54
View File
@@ -1,54 +0,0 @@
import { validateValue } from '../../../src/tools/schema.js';
export function invalidCalls(calls, tools) {
const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
return calls.flatMap((call, index) => {
const schema = schemas.get(call.name);
const errors = !schema
? ['Unknown tool']
: call.parseError
? ['Arguments must be valid JSON']
: validateValue(schema, call.args === undefined ? {} : call.args);
return errors.length
? [{ index, error: errors.slice(0, 4).join('; ').slice(0, 1000) }]
: [];
});
}
// Missing usage stays unknown, never an invented zero-cost request.
export function parseUsage(provider, data) {
const raw = provider === 'gemini' ? data?.usageMetadata : data?.usage;
const number = (value) =>
Number.isSafeInteger(value) && value >= 0 ? value : null;
const inputTokens = number(
provider === 'gemini' ? raw?.promptTokenCount : raw?.prompt_tokens,
);
const candidateTokens = number(
provider === 'gemini' ? raw?.candidatesTokenCount : raw?.completion_tokens,
);
const totalTokens = number(
provider === 'gemini' ? raw?.totalTokenCount : raw?.total_tokens,
);
if (inputTokens === null || candidateTokens === null || totalTokens === null)
return null;
const reasoningTokens =
number(
provider === 'gemini'
? raw?.thoughtsTokenCount
: raw?.completion_tokens_details?.reasoning_tokens,
) ?? 0;
const outputTokens =
candidateTokens + (provider === 'gemini' ? reasoningTokens : 0);
return {
inputTokens,
outputTokens,
totalTokens,
reasoningTokens,
cachedInputTokens:
number(
provider === 'gemini'
? raw?.cachedContentTokenCount
: raw?.prompt_tokens_details?.cached_tokens,
) ?? 0,
};
}
-4
View File
@@ -1,4 +0,0 @@
/** Identify each provider while keeping the project contact URL consistent. */
export function providerUserAgent(name) {
return `gods-eye-view-${name}-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)`;
}
-157
View File
@@ -1,157 +0,0 @@
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
// R (restricted) only — the small, always-in-force slice of the national
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
// payload small enough to refresh (~330 KB for the current P+R set).
const API_URL =
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
'Special_Use_Airspace/FeatureServer/0/query?' +
new URLSearchParams({
where: "TYPE_CODE IN ('P','R')",
outFields: [
'NAME',
'TYPE_CODE',
'UPPER_VAL',
'UPPER_UOM',
'UPPER_CODE',
'LOWER_VAL',
'LOWER_UOM',
'LOWER_CODE',
'TIMESOFUSE',
'CONT_AGENT',
'CITY',
'STATE',
'OBJECTID',
].join(','),
maxAllowableOffset: '0.001',
outSR: '4326',
f: 'geojson',
}).toString();
const MAX_PAGES = 5;
const MIB = 1024 * 1024;
const SNAPSHOT_TTL_MS = 3_600_000;
/** Longest a failed refresh keeps serving the last snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
payload?.properties?.exceededTransferLimit === true;
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
export function droneAirspaceProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
async function upstream(url, cap, timeout) {
const signal = AbortSignal.timeout(timeout);
const response = await fetchImpl(url, {
signal,
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
return readResponseJsonCapped(response, cap, signal);
}
// A snapshot is accepted only once the service says it is complete. A page
// limit reached while still flagged, a flagged page that adds nothing, or
// no valid zone at all is a failed refresh, so the last good snapshot stays.
async function fetchZones() {
const features = [];
let complete = false;
for (let page = 0; page < MAX_PAGES; page++) {
const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
const payload = await upstream(url, 8 * MIB, 30_000);
if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot');
features.push(...payload.features);
if (!exceededTransferLimit(payload)) {
complete = true;
break;
}
if (!payload.features.length) break;
}
if (!complete) throw new Error('incomplete_snapshot');
const rows = normalizeDroneAirspaceSnapshot({ features });
if (!rows?.length) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
}
async function acquire(key, ttl, load) {
const previous = cache.get(key);
if (previous && now() - previous.savedAt < ttl)
return { value: previous.value, stale: false };
if (!inFlight.has(key) && inFlight.size >= 256)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
cache.set(key, { value, savedAt: now() });
return value;
});
return { value: await promise, stale: false };
} catch (error) {
// A failed refresh serves the last snapshot, marked stale, for a day.
if (previous && now() - previous.savedAt <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
// Keyless and needed to render the layer: refuse cross-site browsers
// before they spend a client's quota or start a refresh, but keep the
// documented reverse-proxy deployment working.
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
if (path !== '/' && path !== '')
return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(
'zones',
SNAPSHOT_TTL_MS,
fetchZones,
);
json(200, stale ? { ...value, stale: true } : value, stale);
} catch (error) {
json(error.status === 429 ? 429 : 502, {
error: 'drone_airspace_unavailable',
});
}
}
return {
name: 'drone-airspace',
configureServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
};
}
-5
View File
@@ -1,13 +1,8 @@
import { createGeminiChatHandler } from './gemini/chat.js';
import { createGeminiTokenHandler } from './gemini/realtime.js';
/** Mint constrained Live API credentials; the permanent key stays on Node. */
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
function install(middlewares) {
middlewares.use(
'/api/gemini/chat',
createGeminiChatHandler({ ...realtime, annotationGuidance }),
);
middlewares.use(
'/api/gemini/token',
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
-188
View File
@@ -1,188 +0,0 @@
import { createTextChatHandler } from '../common/textChat.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import { normalizeGeminiModel } from './config.js';
// The Gemini token route's throttle default (GEV_RATELIMIT_GEMINI_PER_MIN).
export const GEMINI_DEFAULT_PER_MIN = 30;
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
// Only text and function exchanges may cross this route, never media or config.
export function validChatContents(contents, names) {
return (
Array.isArray(contents) &&
contents.length > 0 &&
contents.length <= 96 &&
contents.every(
(content) =>
object(content) &&
keys(content, ['role', 'parts']) &&
['user', 'model'].includes(content.role) &&
Array.isArray(content.parts) &&
content.parts.length > 0 &&
content.parts.length <= 32 &&
content.parts.every((part) => {
if (
!object(part) ||
!keys(part, [
'text',
'functionCall',
'functionResponse',
'thoughtSignature',
])
)
return false;
if (
part.thoughtSignature !== undefined &&
(typeof part.thoughtSignature !== 'string' ||
part.thoughtSignature.length > 32000 ||
content.role !== 'model')
)
return false;
if (Object.hasOwn(part, 'text'))
return (
keys(part, ['text', 'thoughtSignature']) &&
typeof part.text === 'string'
);
const call = part.functionCall ?? part.functionResponse;
if (
!object(call) ||
typeof call.name !== 'string' ||
call.name.length > 128 ||
(names && !names.has(call.name)) ||
(call.id !== undefined &&
(typeof call.id !== 'string' || call.id.length > 1024))
)
return false;
if (part.functionCall)
return (
content.role === 'model' &&
keys(part, ['functionCall', 'thoughtSignature']) &&
keys(call, ['name', 'args', 'id']) &&
(call.args === null || object(call.args ?? {}))
);
return (
content.role === 'user' &&
keys(part, ['functionResponse']) &&
keys(call, ['name', 'response', 'id']) &&
object(call.response)
);
}),
)
);
}
export function createGeminiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveChatModel = () =>
process.env.GEMINI_CHAT_MODEL || 'gemini-3.5-flash-lite',
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'gemini',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: GEMINI_DEFAULT_PER_MIN,
prepare(input) {
const model = normalizeGeminiModel(resolveChatModel());
if (!model || /live|audio|tts/i.test(model)) return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['contents']) ||
!validChatContents(input.contents, null)
)
return null;
if (!pairedContents(input.contents)) return null;
const overpass = overpassConfigured();
return {
endpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
headers: (key) => ({ 'x-goog-api-key': key }),
body: {
contents: input.contents,
systemInstruction: {
parts: [
{ text: textInstructions(annotationGuidance, { overpass }) },
],
},
tools: [
{
functionDeclarations: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
}),
),
},
],
generationConfig: {
responseModalities: ['TEXT'],
maxOutputTokens: 4096,
thinkingConfig: { thinkingLevel: 'LOW' },
},
},
};
},
parse(data) {
const candidate = data?.candidates?.[0];
const content = candidate?.content;
if (
!content ||
!validChatContents([content], null) ||
content.role !== 'model'
)
return null;
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
return {
content,
finishReason: candidate.finishReason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
// Never forward an orphan result or an unfinished batch in browser history.
function pairedContents(contents) {
let pending = [];
for (const content of contents) {
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
const results = content.parts
.filter((part) => part.functionResponse)
.map((part) => part.functionResponse);
if (pending.length) {
if (
calls.length ||
results.length !== pending.length ||
results.some(
(result, i) =>
result.name !== pending[i].name || result.id !== pending[i].id,
)
)
return false;
pending = [];
} else if (results.length) return false;
if (calls.length) pending = calls;
}
return !pending.length;
}
+4 -8
View File
@@ -1,5 +1,5 @@
import { realtimeInstructions } from '../openai/instructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
import { GEV_REALTIME_TOOLS } from '../openai/tools.js';
export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live';
export const GEMINI_TOKEN_ENDPOINT =
@@ -14,26 +14,22 @@ export function normalizeGeminiModel(value) {
return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null;
}
/**
* REST BidiGenerateContentSetup fields, excluding its model resource name.
* `overpass` says an operator configured Overpass, which `osm_query` needs.
*/
/** REST BidiGenerateContentSetup fields, excluding its model resource name. */
export function createGeminiLiveConfig(
annotationGuidance,
inputMode = 'open-mic',
tools = GEV_REALTIME_TOOLS,
{ overpass = false } = {},
) {
if (!['open-mic', 'push-to-talk'].includes(inputMode))
throw new Error('Invalid Gemini input mode');
const config = {
generationConfig: { responseModalities: ['AUDIO'] },
systemInstruction: {
parts: [{ text: realtimeInstructions(annotationGuidance, { overpass }) }],
parts: [{ text: realtimeInstructions(annotationGuidance) }],
},
tools: [
{
functionDeclarations: offeredTools(tools, { overpass }).map(
functionDeclarations: tools.map(
({ name, description, parameters }) => ({
name,
description,
-4
View File
@@ -12,7 +12,6 @@ import {
isGeminiRequestOriginAllowed,
readGeminiTokenRequest,
} from './request.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
@@ -20,8 +19,6 @@ const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
export function createGeminiTokenHandler({
annotationGuidance,
tools,
// osm_query is offered only when an operator configured Overpass.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
fetchImpl = (...args) => fetch(...args),
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveModel = () =>
@@ -107,7 +104,6 @@ export function createGeminiTokenHandler({
annotationGuidance,
inputMode,
tools,
{ overpass: overpassConfigured() },
);
const issuedAt = now();
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
-370
View File
@@ -1,370 +0,0 @@
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { normalizeInatObservations } from '../../../src/layers/wildlifeSightings/records.js';
import { readResponseJsonCapped } from '../common/http.js';
import { clampInt } from '../common/query.js';
import { providerUserAgent } from '../common/userAgent.js';
import { projectInatGrid, validInatGridTile } from './grid.js';
import { createInatQueue } from './queue.js';
const DAILY_BUDGET = 8000;
const DAY_MS = 24 * 60 * 60_000;
// One page is capped independently of upstream Content-Length.
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const TIMEOUT_MS = 20_000;
const API_URL = 'https://api.inaturalist.org/v1';
// Request only the fields used by the public observation projection.
const OBSERVATIONS_URL = 'https://api.inaturalist.org/v2/observations';
const OBSERVATION_FIELDS =
'(id:!t,uri:!t,location:!t,geojson:all,geoprivacy:!t,taxon_geoprivacy:!t,' +
'obscured:!t,quality_grade:!t,observed_on:!t,' +
'time_observed_at:!t,faves_count:!t,' +
'num_identification_agreements:!t,' +
'photos:(url:!t,attribution:!t,license_code:!t),' +
'taxon:(name:!t,preferred_common_name:!t,iconic_taxon_name:!t,' +
'threatened:!t))';
const COOLDOWN_MS = 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Seconds and HTTP dates are accepted; a missing value earns one minute. */
export function inatRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0) return delay;
}
return COOLDOWN_MS;
}
function upstreamError(code, status, retryAfterS, upstreamStatus) {
return Object.assign(new Error(code), {
code,
status,
retryAfterS,
upstreamStatus,
});
}
function observationFilters(params) {
const query = new URLSearchParams({
verifiable: 'true',
captive: 'false',
// d1 is an observation DATE, unlike creation-time created_d1.
d1: params.observedD1,
});
if (params.taxa !== 'whales') query.set('photos', 'true');
if (params.quality === 'research') query.set('quality_grade', 'research');
if (params.taxa === 'whales') query.set('taxon_id', '152871');
else if (['Aves', 'Mammalia', 'Insecta', 'Plantae'].includes(params.taxa))
query.set('iconic_taxa', params.taxa);
return query;
}
/**
* Shared iNaturalist governor. Only request counters are persisted; observation
* payloads are projected immediately and are never written to disk.
*/
export function createInatClient({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir = path.join(process.cwd(), '.gev-cache', 'inaturalist'),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const enqueue = createInatQueue(queueOptions);
let budget = null;
let budgetLoaded = false;
let budgetTail = Promise.resolve();
let cooldownUntil = 0;
// An unavailable UTFGrid tile must never place observation queries into a
// 502 cooldown. Only an upstream 429 is a provider-wide restriction.
const failures = new Map();
function dailyLimit() {
return clampInt(
process.env.INATURALIST_DAILY_BUDGET,
1,
Number.MAX_SAFE_INTEGER,
DAILY_BUDGET,
);
}
function day() {
return new Date(now()).toISOString().slice(0, 10);
}
function currentBudget() {
const date = day();
if (budget?.date !== date) budget = { date, count: 0 };
return budget;
}
async function readBudgetOnce() {
if (budgetLoaded) return;
budgetLoaded = true;
if (cacheDir === null) return;
try {
const value = JSON.parse(
await fs.readFile(path.join(cacheDir, 'budget.json'), 'utf8'),
);
if (
typeof value?.date === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(value.date) &&
Number.isSafeInteger(value.count) &&
value.count >= 0
)
budget = { date: value.date, count: value.count };
} catch (error) {
if (error.code !== 'ENOENT')
warn('[inaturalist-proxy] budget read failed');
}
}
async function persistBudget(value) {
if (cacheDir === null) return;
const destination = path.join(cacheDir, 'budget.json');
const temporary = `${destination}.${randomUUID()}.tmp`;
try {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(temporary, JSON.stringify(value), 'utf8');
await fs.rename(temporary, destination);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
warn('[inaturalist-proxy] budget write failed');
}
}
function retrySeconds(until = cooldownUntil) {
return Math.max(0, Math.ceil((until - now()) / 1000));
}
function checkCooldown(resource) {
if (now() < cooldownUntil)
throw upstreamError('inaturalist_rate_limited', 429, retrySeconds(), 429);
const failure = failures.get(resource);
if (failure && now() < failure.until)
throw upstreamError(
'inaturalist_unavailable',
502,
retrySeconds(failure.until),
failure.status,
);
failures.delete(resource);
}
// Reserve and atomically persist one attempt before issuing it. A recovered
// tail prevents one disk/reservation failure from poisoning future calls.
function reserveAttempt(signal, resource) {
const result = budgetTail.then(async () => {
signal?.throwIfAborted();
checkCooldown(resource);
await readBudgetOnce();
signal?.throwIfAborted();
checkCooldown(resource);
const value = currentBudget();
if (value.count >= dailyLimit()) {
const retry = Math.max(
1,
Math.ceil((Date.parse(`${day()}T00:00:00Z`) + DAY_MS - now()) / 1000),
);
throw upstreamError('inaturalist_budget_exhausted', 503, retry);
}
value.count += 1;
await persistBudget({ ...value });
});
budgetTail = result.catch(() => {});
return result;
}
async function requestProjected(url, project, resource, { signal } = {}) {
signal?.throwIfAborted();
checkCooldown(resource);
return enqueue(() => dispatch(url, project, resource, signal), {
signal,
priority: resource === 'observations' ? 0 : 1,
});
}
async function dispatch(url, project, resource, signal) {
signal?.throwIfAborted();
await reserveAttempt(signal, resource);
signal?.throwIfAborted();
checkCooldown(resource);
const timeout = AbortSignal.timeout(TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let upstreamStatus;
let retryDelay = FAILURE_COOLDOWN_MS;
try {
const response = await fetchImpl(url, {
signal: requestSignal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
upstreamStatus = response.status;
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
cooldownUntil = Math.max(cooldownUntil, now() + delay);
throw upstreamError(
'inaturalist_rate_limited',
429,
retrySeconds(),
429,
);
}
if (response.headers.has('retry-after'))
retryDelay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
throw upstreamError('inaturalist_unavailable', 502);
}
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
requestSignal,
);
return project(payload);
} catch (error) {
// Log only safe diagnostic categories, never a URL, body, status text or
// exception message (each can contain request/private information).
const reason = signal?.aborted
? 'abort'
: timeout.aborted || error.name === 'TimeoutError'
? 'timeout'
: upstreamStatus && upstreamStatus >= 400
? 'http'
: error.code === 'RESPONSE_TOO_LARGE'
? 'response-too-large'
: upstreamStatus
? 'invalid-payload'
: 'network';
warn(
`[inaturalist-proxy] upstream refresh failed resource=${resource} status=${upstreamStatus ?? 'none'} reason=${reason}`,
);
if (signal?.aborted) throw error;
// Network, abort, schema and size failures are not upstream cooldown
// signals. A later healthy request may recover immediately.
if (upstreamStatus >= 500)
failures.set(resource, {
until: now() + retryDelay,
status: upstreamStatus,
});
const failure = failures.get(resource);
throw upstreamError(
error.status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable',
error.status === 429 ? 429 : 502,
error.status === 429
? retrySeconds()
: failure
? retrySeconds(failure.until)
: undefined,
upstreamStatus === 429 || upstreamStatus >= 500
? upstreamStatus
: undefined,
);
}
}
/** Request one bounded observation sample and project its public fields. */
async function getObservations(params, options = {}) {
const query = observationFilters(params);
// View samples stay stable in the route cache until their TTL expires.
query.set('order_by', params.scope === 'view' ? 'random' : 'observed_on');
if (params.scope !== 'view') query.set('order', 'desc');
query.set('per_page', '200');
query.set('fields', OBSERVATION_FIELDS);
if (params.scope === 'view') {
query.set('swlat', String(params.box.south));
query.set('swlng', String(params.box.west));
query.set('nelat', String(params.box.north));
query.set('nelng', String(params.box.east));
}
return requestProjected(
`${OBSERVATIONS_URL}?${query}`,
(payload) => {
if (
!Array.isArray(payload?.results) ||
!Number.isSafeInteger(payload.total_results) ||
payload.total_results < 0
)
throw upstreamError('inaturalist_unavailable', 502);
const rows = normalizeInatObservations(payload.results, {
nowMs: now(),
windowMs: (params.taxa === 'whales' ? 7 : 2) * DAY_MS,
});
// Empty views need total_results: 0. A wholly invalid page must not
// replace good data. Valid rows excluded by the finer rolling-time
// cutoff are a healthy empty result of the upstream DATE over-fetch.
if (
!rows ||
(!rows.length &&
(payload.results.length || payload.total_results) &&
!normalizeInatObservations(payload.results)?.length)
)
throw upstreamError('inaturalist_unavailable', 502);
return {
rows,
totalResults: payload.total_results,
...(payload.total_results > payload.results.length
? { partial: true, rawRowCount: payload.total_results }
: {}),
};
},
'observations',
options,
);
}
/** Decode one validated density tile without observation identifiers. */
async function getGrid(params, options = {}) {
if (!validInatGridTile(params)) throw new TypeError('Invalid grid tile');
const query = observationFilters(params);
return requestProjected(
`${API_URL}/grid/${params.z}/${params.x}/${params.y}.grid.json?${query}`,
(payload) => projectInatGrid(payload, params),
'grid',
options,
);
}
return {
getObservations,
getGrid,
/** Current provider-wide or resource-specific retry information. */
cooldownState(resource) {
if (now() < cooldownUntil)
return {
upstreamStatus: 429,
retryAfterS: retrySeconds(),
};
const failure = failures.get(resource);
if (failure && now() < failure.until)
return {
upstreamStatus: failure.status,
retryAfterS: retrySeconds(failure.until),
};
return undefined;
},
/** The current UTC day's reserved upstream attempts and configured limit. */
budgetState() {
const value = currentBudget();
return { ...value, budget: dailyLimit() };
},
};
}
@@ -1,163 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { syntheticGrid } from './grid.fixture.mjs';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const observation = (id, time) => ({
id,
geojson: { coordinates: [-122.3, 47.6] },
quality_grade: 'research',
time_observed_at: time,
observed_on: time.slice(0, 10),
// Upload age does not determine whether an observation is recent.
created_at: '2026-11-04T18:00:00Z',
taxon: { name: 'Synthetic taxon', iconic_taxon_name: 'Aves' },
});
function clientFor(results) {
return createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () =>
Response.json({ results, total_results: results.length }),
});
}
test('iNaturalist drops ancient and boundary-expired observations uploaded today', async () => {
const client = clientFor([
observation(1, '2010-11-04T18:00:00Z'),
observation(2, '2026-11-02T18:04:59Z'),
observation(3, '2026-11-02T18:05:00Z'),
]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(
result.rows.map((row) => row.stableId),
['inat:3'],
);
});
test('a valid date-filtered page can be empty after the exact recency cutoff', async () => {
const client = clientFor([observation(1, '2026-11-02T06:00:00Z')]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(result.rows, []);
assert.equal(result.totalResults, 1);
});
const params = {
scope: 'view',
box: { south: 47, west: -123, north: 48, east: -122 },
taxa: 'all',
observedD1: '2026-11-02',
};
test('observations and tiles share pacing, and observation priority wins a cold burst', async () => {
let at = 0;
const calls = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW + at,
queueOptions: {
now: () => at,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push([isGrid ? 'grid' : 'observations', at]);
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const tiles = Array.from({ length: 3 }, (_, x) =>
client.getGrid({ ...params, z: 2, x, y: 1 }),
);
await Promise.all([...tiles, client.getObservations(params)]);
assert.deepEqual(calls, [
['observations', 0],
['grid', 0],
['grid', 0],
['grid', 0],
]);
assert.equal(client.budgetState().count, 4);
});
test('upstream status and safe failure reasons are logged without upstream messages or URLs', async () => {
for (const [response, expected] of [
[
() =>
new Response('request-secret', {
status: 503,
statusText: 'request-secret',
}),
/status=503 reason=http/,
],
[
() => Promise.reject(new DOMException('request-secret', 'TimeoutError')),
/status=none reason=timeout/,
],
[
() => Response.json({ unexpected: 'request-secret' }),
/status=200 reason=invalid-payload/,
],
]) {
const warnings = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: response,
});
await assert.rejects(client.getObservations(params), { status: 502 });
assert.equal(warnings.length, 1);
assert.match(warnings[0], expected);
assert.doesNotMatch(warnings[0], /request-secret|https?:|swlat|d1=/);
}
});
test('a dispatched abort is diagnosed, spends only its attempt, and leaves the next request healthy', async () => {
const warnings = [];
const controller = new AbortController();
let started;
const didStart = new Promise((resolve) => {
started = resolve;
});
let calls = 0;
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async (url, { signal }) => {
calls += 1;
if (calls > 1) return Response.json({ results: [], total_results: 0 });
started();
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = client.getObservations(params, { signal: controller.signal });
const failed = assert.rejects(pending, { name: 'AbortError' });
await didStart;
controller.abort();
await failed;
assert.match(warnings[0], /resource=observations status=none reason=abort/);
await client.getObservations(params);
assert.equal(calls, 2);
});
@@ -1,51 +0,0 @@
/** Build a synthetic UTFGrid with logical cell counts and repeated hit pixels. */
export function syntheticGrid({
z = 2,
x = 1,
y = 1,
cells = [
{ row: 1, column: 51, count: 6, iconic: 3 },
{ row: 3, column: 49, count: 12, iconic: 47170 },
],
} = {}) {
const pixels = Array.from({ length: 64 }, () => Array(64).fill(' '));
const keys = [''];
const data = {};
cells.forEach((cell, index) => {
const key = String(900_000_000 + index);
keys.push(key);
const value = index + 1;
let code = value + 32;
if (code >= 34) code += 1;
if (code >= 92) code += 1;
const character = String.fromCharCode(code);
for (const [row, column] of [
[cell.row, cell.column],
[cell.row - 1, cell.column],
[cell.row + 1, cell.column],
[cell.row, cell.column - 1],
[cell.row, cell.column + 1],
]) {
if (row >= 0 && row < 64 && column >= 0 && column < 64)
pixels[row][column] = character;
}
data[key] = {
captive: null,
cellCount: cell.count,
geoprivacy: null,
id: Number(key),
latitude:
(Math.atan(
Math.sinh(Math.PI * (1 - 2 * ((y + cell.row / 64) / 2 ** z))),
) *
180) /
Math.PI,
longitude: ((x + cell.column / 64) / 2 ** z) * 360 - 180,
private_location: null,
quality_grade: 'needs_id',
'taxon.iconic_taxon_id': cell.iconic,
uuid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
};
});
return { grid: pixels.map((row) => row.join('')), keys, data };
}
-127
View File
@@ -1,127 +0,0 @@
// iNaturalist's operational /v1/grid/{z}/{x}/{y}.grid.json endpoint is not
// covered by its v1 Swagger contract. Validate the entire UTFGrid shape before
// accepting any cell; callers preserve the last good sample on schema drift.
import {
wildlifeGridCellId,
WILDLIFE_GRID_MAX_ZOOM,
} from '../../../src/layers/wildlifeSightings/gridViewport.js';
const INAT_GRID_SIZE = 64;
export const INAT_GRID_DECODE_MEASURE = 'wildlife:server:tile-decode';
const grades = new Set(['research', 'needs_id', 'casual']);
const object = (value) =>
!!value && typeof value === 'object' && !Array.isArray(value);
/** Accept only integer XYZ coordinates within the supported zoom range. */
export function validInatGridTile({ z, x, y } = {}) {
return (
Number.isInteger(z) &&
z >= 0 &&
z <= WILDLIFE_GRID_MAX_ZOOM &&
Number.isInteger(x) &&
Number.isInteger(y) &&
x >= 0 &&
y >= 0 &&
x < 2 ** z &&
y < 2 ** z
);
}
/** Pure allow-list projection. A UTFGrid key is an observation identifier and
* its repeated pixels form a dilated hit area, not additional occupied cells.
* Neither those keys nor representative/private fields cross this boundary. */
export function projectInatGrid(payload, tile) {
const start = `${INAT_GRID_DECODE_MEASURE}:start`;
const end = `${INAT_GRID_DECODE_MEASURE}:end`;
performance.mark(start);
try {
return decodeInatGrid(payload, tile);
} finally {
performance.mark(end);
// Keep the latest sample inspectable without retaining every tile forever.
// PerformanceObservers still receive each individual decode measurement.
performance.clearMeasures(INAT_GRID_DECODE_MEASURE);
performance.measure(INAT_GRID_DECODE_MEASURE, start, end);
performance.clearMarks(start);
performance.clearMarks(end);
}
}
function decodeInatGrid(payload, tile) {
const invalid = () => {
throw new TypeError('Invalid iNaturalist UTFGrid');
};
if (
!validInatGridTile(tile) ||
!object(payload) ||
!Array.isArray(payload.grid) ||
payload.grid.length !== INAT_GRID_SIZE ||
!payload.grid.every(
(row) => typeof row === 'string' && row.length === INAT_GRID_SIZE,
) ||
!Array.isArray(payload.keys) ||
!payload.keys.length ||
payload.keys.length > INAT_GRID_SIZE ** 2 + 1 ||
payload.keys[0] !== '' ||
!payload.keys.every(
(key) => typeof key === 'string' && key.length <= 128,
) ||
new Set(payload.keys).size !== payload.keys.length ||
!object(payload.data)
)
invalid();
const dataKeys = Object.keys(payload.data);
if (dataKeys.length !== payload.keys.length - 1) invalid();
for (const key of payload.keys.slice(1)) {
if (!key || !Object.hasOwn(payload.data, key)) invalid();
const value = payload.data[key];
if (
!object(value) ||
!Number.isSafeInteger(value.cellCount) ||
value.cellCount <= 0 ||
!Number.isFinite(value.latitude) ||
Math.abs(value.latitude) > 90 ||
!Number.isFinite(value.longitude) ||
Math.abs(value.longitude) > 180 ||
!grades.has(value.quality_grade) ||
!(
value['taxon.iconic_taxon_id'] === null ||
(Number.isSafeInteger(value['taxon.iconic_taxon_id']) &&
value['taxon.iconic_taxon_id'] > 0)
)
)
invalid();
}
const occupied = new Set();
for (const line of payload.grid) {
for (let column = 0; column < INAT_GRID_SIZE; column += 1) {
let code = line.charCodeAt(column);
if (code < 32 || code === 34 || code === 92) invalid();
if (code >= 93) code -= 1;
if (code >= 35) code -= 1;
const index = code - 32;
if (index < 0 || index >= payload.keys.length) invalid();
if (index) occupied.add(payload.keys[index]);
}
}
if (occupied.size !== dataKeys.length) invalid();
const cells = new Map();
for (const key of occupied) {
const value = payload.data[key];
const id = wildlifeGridCellId(value.latitude, value.longitude, tile.z);
const cell = {
id,
cellCount: value.cellCount,
lat: value.latitude,
lon: value.longitude,
};
// Two different representatives claiming one logical cell are a contract
// change; summing or choosing one silently would fabricate the count.
if (cells.has(id)) invalid();
cells.set(id, cell);
}
const rows = [...cells.values()];
const totalResults = rows.reduce((total, cell) => total + cell.cellCount, 0);
if (!Number.isSafeInteger(totalResults)) invalid();
return { cells: rows, totalResults };
}
-374
View File
@@ -1,374 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { projectInatGrid } from './grid.js';
import { syntheticGrid } from './grid.fixture.mjs';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient } from './client.js';
const tile = { z: 2, x: 1, y: 1 };
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves&quality=research';
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('UTFGrid projection validates the full shape and counts each logical occupied cell once', () => {
const raw = syntheticGrid();
const projected = projectInatGrid(raw, tile);
assert.equal(raw.grid.length, 64);
assert.ok(raw.grid.every((row) => row.length === 64));
assert.equal(
raw.grid.join('').split('!').length - 1,
5,
'one key is repeated by its hit area',
);
assert.equal(projected.cells.length, 2);
assert.equal(projected.totalResults, 18);
assert.deepEqual(projected.cells[0], {
id: 'grid:2/1/1:1:51',
cellCount: 6,
lat: raw.data[raw.keys[1]].latitude,
lon: raw.data[raw.keys[1]].longitude,
});
for (const cell of projected.cells)
assert.deepEqual(Object.keys(cell).sort(), [
'cellCount',
'id',
'lat',
'lon',
]);
for (const privateField of [
'900000000',
'uuid',
'geoprivacy',
'private_location',
'captive',
'quality_grade',
])
assert.ok(!JSON.stringify(projected).includes(privateField));
});
test('legitimate blank grid and null/unknown iconic ids remain safe', () => {
assert.deepEqual(projectInatGrid(syntheticGrid({ cells: [] }), tile), {
cells: [],
totalResults: 0,
});
const raw = syntheticGrid({
cells: [{ row: 1, column: 1, count: 1, iconic: null }],
});
const projected = projectInatGrid(raw, tile);
assert.equal(projected.cells[0].cellCount, 1);
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 999999;
assert.deepEqual(projectInatGrid(raw, tile), projected);
});
test('schema drift anywhere fails closed, including unused, malformed and orphan cells', () => {
const changes = [
(raw) => {
raw.grid.pop();
},
(raw) => {
raw.grid[0] = '';
},
(raw) => {
raw.grid[0] = '~'.repeat(64);
},
(raw) => {
raw.keys[0] = 'not-empty';
},
(raw) => {
raw.keys.push(raw.keys[1]);
},
(raw) => {
delete raw.data[raw.keys[1]];
},
(raw) => {
raw.data.extra = {};
},
(raw) => {
raw.data[raw.keys[1]].cellCount = -1;
},
(raw) => {
raw.data[raw.keys[1]].latitude = '47';
},
(raw) => {
raw.data[raw.keys[1]].longitude = 181;
},
(raw) => {
raw.data[raw.keys[1]].quality_grade = 'new-grade';
},
(raw) => {
delete raw.data[raw.keys[1]]['taxon.iconic_taxon_id'];
},
(raw) => {
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 'Aves';
},
(raw) => {
raw.grid = Array(64).fill(' '.repeat(64));
},
];
for (const change of changes) {
const raw = syntheticGrid();
change(raw);
assert.throws(
() => projectInatGrid(raw, tile),
/Invalid iNaturalist UTFGrid/,
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fixed grid URL shares filters, caps and allow-list cache`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.match(
options.headers['User-Agent'],
/^gods-eye-view-wildlife-sightings-proxy/,
);
return Response.json(syntheticGrid());
},
},
hook,
);
assert.equal(calls.length, 0);
const response = await request(
`${gridUrl}&url=https://attacker.test&per_page=999`,
);
assert.equal(response.status, 200);
assert.equal(response.body.cells.length, 2);
assert.equal(response.body.fetchedAt, NOW);
assert.equal(response.headers['Cache-Control'], 'no-store');
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v1/grid/2/1/1.grid.json',
);
assert.deepEqual(Object.fromEntries(calls[0].searchParams), {
verifiable: 'true',
captive: 'false',
d1: '2026-11-02',
photos: 'true',
quality_grade: 'research',
iconic_taxa: 'Aves',
});
await request(gridUrl);
assert.equal(calls.length, 1, 'fresh stable tile/filter key is cached');
assert.ok(!JSON.stringify([...cache]).includes('private_location'));
assert.ok(!JSON.stringify([...cache]).includes('900000000'));
const whale = await request('/grid?z=2&x=1&y=1&taxa=whales&confirmed=true');
assert.equal(whale.status, 200);
assert.equal(calls[1].searchParams.get('taxon_id'), '152871');
assert.equal(calls[1].searchParams.get('d1'), '2026-10-28');
assert.equal(calls[1].searchParams.has('photos'), false);
});
}
test('grid admission rejects cross-site/method/path/tile before acquisition', async () => {
const request = install({
fetchImpl() {
assert.fail('invalid grid request acquired');
},
});
assert.equal(
(
await request(gridUrl, {
headers: {
host: 'localhost:5173',
origin: 'https://other.test',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
assert.equal((await request('/grid', { method: 'POST' })).status, 405);
assert.equal((await request('/grid/extra')).status, 404);
for (const query of [
'',
'z=2&x=4&y=0',
'z=-1&x=0&y=0',
'z=23&x=0&y=0',
'z=2&x=1.5&y=0',
'z=2&x=&y=0',
'z=2&x=0&y=4',
]) {
const result = await request(`/grid?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_tile');
}
});
test('grid refresh single-flight, stale fallback and hour-key expiry keep the original age', async () => {
let time = NOW;
let calls = 0;
let resolve;
let mode = 'pending';
const request = install({
now: () => time,
fetchImpl: async () => {
calls += 1;
if (mode === 'pending')
return new Promise((done) => {
resolve = done;
});
if (mode === 'bad') return Response.json({ grid: [] });
return Response.json(syntheticGrid());
},
});
const first = request();
const second = request();
for (let i = 0; i < 100 && !resolve; i += 1) await new Promise(setImmediate);
assert.equal(calls, 1);
resolve(Response.json(syntheticGrid()));
assert.equal((await first).status, 200);
assert.equal((await second).status, 200);
time += 900_001;
mode = 'bad';
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.equal(stale.headers['X-Data-Stale'], 'true');
time = NOW + 3_600_001;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
});
test('grid and observations share the upstream cooldown and daily reservations', async () => {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () => {
calls += 1;
return new Response('', {
status: 429,
headers: { 'Retry-After': '120' },
});
},
});
await assert.rejects(
client.getGrid({ ...tile, taxa: 'all', observedD1: '2026-11-02' }),
(error) => error.status === 429 && error.retryAfterS === 120,
);
await assert.rejects(
client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
}),
(error) => error.status === 429,
);
assert.equal(calls, 1);
assert.equal(client.budgetState().count, 1);
});
test('grid body cap and upstream failures return fixed errors; only real 5xx cools down', async () => {
for (const [response, retryAfter] of [
[() => new Response('x'.repeat(8 * 1024 * 1024 + 1)), undefined],
[() => new Response('', { status: 503 }), '15'],
]) {
const request = install({ fetchImpl: async () => response() });
const result = await request();
assert.equal(result.status, 502);
assert.deepEqual(result.body, { error: 'inaturalist_unavailable' });
assert.equal(result.headers['Retry-After'], retryAfter);
}
});
test('a failed tile preserves last good cells and cannot send unrelated observations into 502', async () => {
let at = NOW;
let failGrid = false;
const calls = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push(isGrid ? 'grid' : 'observations');
if (isGrid && failGrid)
return new Response('private upstream text', {
status: 503,
headers: { 'Retry-After': '45' },
});
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const first = await request();
at += 900_001;
failGrid = true;
const failed = await request();
assert.equal(failed.status, 200);
assert.equal(failed.headers['X-Data-Stale'], 'true');
assert.equal(failed.body.stale, true);
assert.equal(failed.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(failed.body.cells, first.body.cells);
assert.equal(
(await request('/observations?scope=global&taxa=whales')).status,
200,
);
assert.deepEqual(calls, ['grid', 'grid', 'observations']);
assert.match(warnings[0], /resource=grid status=503 reason=http/);
assert.doesNotMatch(warnings[0], /private upstream text/);
// The tile cooldown also preserves its data without another upstream try.
const cooling = await request();
assert.equal(cooling.body.stale, true);
assert.equal(cooling.headers['Retry-After'], '45');
assert.equal(cooling.headers['X-Upstream-Status'], '503');
assert.equal(calls.length, 3);
at += 45_000;
failGrid = false;
const refreshing = await request();
assert.equal(refreshing.body.stale, true);
await new Promise(setImmediate);
const recovered = await request();
assert.equal(recovered.status, 200);
assert.equal(recovered.body.stale, undefined);
assert.equal(
calls.length,
4,
'a failed queue task does not poison later tile work',
);
});
test('twelve queued tiles leave observation capacity and give the next dispatch to observations', async () => {
const calls = [];
let release;
const request = install({
fetchImpl: (url) => {
const path = new URL(url).pathname;
calls.push(path);
if (calls.length === 1)
return new Promise((resolve) => {
release = resolve;
});
return Response.json(
path.includes('/grid/')
? syntheticGrid()
: { results: [], total_results: 0 },
);
},
});
const first = request('/grid?z=4&x=0&y=1');
for (let i = 0; i < 100 && !release; i += 1) await new Promise(setImmediate);
assert.ok(release);
const tiles = Array.from({ length: 11 }, (_, i) =>
request(`/grid?z=4&x=${i + 1}&y=1`),
);
const observation = request('/observations?scope=global');
const queuedTile = request('/grid?z=4&x=12&y=1');
release(Response.json(syntheticGrid()));
const results = await Promise.all([first, observation, ...tiles, queuedTile]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls.length, 14);
assert.equal(calls[1], '/v2/observations');
});
@@ -1,343 +0,0 @@
import { createInatClient } from './client.js';
import { coalesceProxyRequest } from '../common/http.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
import { snapWildlifeBox } from '../../../src/layers/wildlifeSightings/bounds.js';
import { validInatGridTile } from './grid.js';
// The upstream advertises five-minute freshness; global samples cost less often.
const TTL_MS = 300_000;
const GLOBAL_TTL_MS = 600_000;
const INAT_GRID_CACHE_TTL_MS = 15 * 60_000;
const STALE_MAX_MS = 3_600_000;
const INAT_CACHE_MAX = 400;
const HOUR_MS = 3_600_000;
const TAXA = Object.freeze([
'all',
'Aves',
'Mammalia',
'Insecta',
'Plantae',
'whales',
]);
function requestParams(pathname, query, nowMs) {
const grid = pathname === '/grid';
const scope = grid ? 'grid' : (query.get('scope') ?? 'view');
if (!grid && scope !== 'view' && scope !== 'global')
return { error: 'invalid_scope' };
const quality =
query.get('quality') === 'research' ||
(grid && query.get('confirmed') === 'true')
? 'research'
: 'all';
const taxa = TAXA.includes(query.get('taxa')) ? query.get('taxa') : 'all';
let box = null;
let tile;
if (grid) {
tile = Object.fromEntries(
['z', 'x', 'y'].map((name) => [
name,
requiredFiniteQueryNumber(query, name),
]),
);
if (!validInatGridTile(tile)) return { error: 'invalid_tile' };
} else if (scope === 'view') {
const south = requiredFiniteQueryNumber(query, 'south');
const west = requiredFiniteQueryNumber(query, 'west');
const north = requiredFiniteQueryNumber(query, 'north');
const east = requiredFiniteQueryNumber(query, 'east');
box = snapWildlifeBox({ south, west, north, east });
if (!box) return { error: 'invalid_bbox' };
}
const windowMs = (taxa === 'whales' ? 7 * 24 : 48) * HOUR_MS;
const windowKey = new Date(Math.floor((nowMs - windowMs) / HOUR_MS) * HOUR_MS)
.toISOString()
.replace('.000Z', 'Z');
const bounds = box ? `${box.south},${box.west},${box.north},${box.east}` : '';
const baseKey = grid
? `grid:v1:${tile.z}/${tile.x}/${tile.y}:${quality}:${taxa}`
: `v1:${scope}:${bounds}:${quality}:${taxa}`;
return {
scope,
...(grid
? { ...tile, waitForFresh: query.get('refresh') === 'wait' }
: { box }),
quality,
taxa,
observedD1: windowKey.slice(0, 10),
baseKey,
key: `${baseKey}:${windowKey}`,
};
}
/** A shared-provider route for dev and preview; acquisition starts on demand. */
export function inaturalistProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir,
cache = new Map(),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const inFlight = new Map();
const consumers = new Map();
let client = null;
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
function joinFlight(promise, group, signal) {
group.count += 1;
return new Promise((resolve, reject) => {
let released = false;
function release() {
if (released) return;
released = true;
signal?.removeEventListener('abort', abort);
group.count -= 1;
if (!group.count && !group.background && !group.settled) {
group.controller.abort();
// A fresh consumer must not join an abandoned queued generation.
if (inFlight.get(group.key) === promise) inFlight.delete(group.key);
if (consumers.get(group.key) === group) consumers.delete(group.key);
}
}
function abort() {
release();
reject(signal.reason);
}
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
promise.then(
(value) => {
release();
resolve(value);
},
(error) => {
release();
reject(error);
},
);
});
}
function refresh(params, background = false) {
// Global samples share pending refreshes across hourly window changes.
const flightKey = params.scope === 'global' ? params.baseKey : params.key;
let group = consumers.get(flightKey);
if (!group) {
group = {
key: flightKey,
controller: new AbortController(),
count: 0,
settled: false,
background,
};
consumers.set(flightKey, group);
}
group.background ||= background;
const { promise, shared } = coalesceProxyRequest(
inFlight,
flightKey,
async () => {
client ??= createInatClient({
fetchImpl,
now,
cacheDir,
warn,
queueOptions,
});
const options = { signal: group.controller.signal };
const result = await (params.scope === 'grid'
? client.getGrid(params, options)
: client.getObservations(params, options));
const value = {
fetchedAt: now(),
...(params.scope === 'grid'
? {}
: { scope: params.scope, box: params.box }),
...result,
};
cache.delete(params.key);
cache.set(params.key, {
value,
savedAt: now(),
baseKey: params.baseKey,
});
while (cache.size > INAT_CACHE_MAX)
cache.delete(cache.keys().next().value);
return value;
},
);
if (!shared) {
const settle = () => {
group.settled = true;
if (consumers.get(flightKey) === group) consumers.delete(flightKey);
};
void promise.then(settle, settle);
}
return { promise, group };
}
async function acquire(params, signal) {
signal?.throwIfAborted();
for (const [key, entry] of cache) {
if (age(entry) > STALE_MAX_MS) cache.delete(key);
}
const current = cache.get(params.key);
const ttl =
params.scope === 'grid'
? INAT_GRID_CACHE_TTL_MS
: params.scope === 'global'
? GLOBAL_TTL_MS
: TTL_MS;
if (current && age(current) < ttl) {
cache.delete(params.key);
cache.set(params.key, current);
return { value: current.value, stale: false };
}
// Window-hour rollover can reuse prior cells without claiming a new age.
let previous = current;
let previousKey = params.key;
if (!previous) {
for (const [key, entry] of cache) {
if (
entry.baseKey === params.baseKey &&
(!previous || entry.savedAt > previous.savedAt)
) {
previous = entry;
previousKey = key;
}
}
}
if (params.scope === 'global' && age(previous) < GLOBAL_TTL_MS)
return { value: previous.value, stale: false };
if (
params.scope === 'grid' &&
!params.waitForFresh &&
age(previous) <= STALE_MAX_MS
) {
cache.delete(previousKey);
cache.set(previousKey, previous);
// Retain one background consumer after this HTTP response closes. A stale
// tile is useful immediately; waiting for the token queue defeats SWR.
const cooling = client?.cooldownState('grid');
if (!cooling) {
const { promise } = refresh(params, true);
void promise.catch(() => {});
}
return {
value: previous.value,
stale: true,
...cooling,
};
}
try {
const { promise, group } = refresh(params);
return { value: await joinFlight(promise, group, signal), stale: false };
} catch (error) {
if (signal?.aborted) throw error;
if (age(previous) <= STALE_MAX_MS)
return {
value: previous.value,
stale: true,
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
};
throw error;
}
}
async function handler(req, res) {
const json = (
status,
value,
{ stale = false, retryAfterS, upstreamStatus } = {},
) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(Number.isFinite(retryAfterS)
? { 'Retry-After': String(retryAfterS) }
: {}),
...(upstreamStatus
? { 'X-Upstream-Status': String(upstreamStatus) }
: {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const url = String(req.url || '/');
const queryAt = url.indexOf('?');
const pathname = queryAt < 0 ? url : url.slice(0, queryAt);
if (pathname !== '/observations' && pathname !== '/grid')
return json(404, { error: 'unknown_route' });
const params = requestParams(
pathname,
new URLSearchParams(queryAt < 0 ? '' : url.slice(queryAt + 1)),
now(),
);
if (params.error) return json(400, { error: params.error });
const controller = new AbortController();
const abort = () => controller.abort();
// IncomingMessage.close also fires after a complete request body. The
// response close identifies the abandoned browser fetch, while aborted
// covers a request cut short before it reached the response stage.
req.once?.('aborted', abort);
res.once?.('close', abort);
try {
const { value, stale, retryAfterS, upstreamStatus } = await acquire(
params,
controller.signal,
);
return json(200, stale ? { ...value, stale: true } : value, {
stale,
retryAfterS,
upstreamStatus,
});
} catch (error) {
if (controller.signal.aborted) return;
const status =
error.code === 'inaturalist_budget_exhausted'
? 503
: error.status === 429
? 429
: 502;
const code =
error.code === 'inaturalist_budget_exhausted'
? error.code
: status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable';
return json(
status,
{ error: code },
{
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
},
);
} finally {
req.removeListener?.('aborted', abort);
res.removeListener?.('close', abort);
}
}
return {
name: 'inaturalist',
configureServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
};
}
@@ -1,893 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient, inatRetryCooldownMs } from './client.js';
const observation = (id = 1) => ({
id,
uri: `https://www.inaturalist.org/observations/${id}`,
geojson: { coordinates: [-122.351, 47.613] },
time_observed_at: '2026-11-04T17:00:00Z',
observed_on: '2026-11-04',
created_at: '2026-11-04T17:01:00Z',
quality_grade: 'research',
license_code: 'cc-by',
positional_accuracy: 12,
faves_count: 2,
num_identification_agreements: 3,
taxon: {
name: 'Calypte anna',
preferred_common_name: "Anna's hummingbird",
iconic_taxon_name: 'Aves',
},
photos: [
{
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
license_code: 'cc-by',
attribution: 'Synthetic photographer, CC BY',
},
],
user: { login: 'must-never-leave-server' },
});
const envelope = (rows = [observation()], total = rows.length) => ({
total_results: total,
page: 1,
per_page: 200,
results: rows,
});
const boxUrl = (index = 0, extra = '') =>
`/observations?south=47.51&west=${-122.49 + index * 0.2}&north=47.69&east=${-122.31 + index * 0.2}${extra}`;
function install(options = {}, hook = 'configureServer') {
return installInatProxy(
boxUrl(),
{
// Serial dispatch makes queued cancellation and coalescing observable.
queueOptions: { intervalMs: 0, windowMs: 0, maxInFlight: 1 },
...options,
},
hook,
);
}
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('expected fake upstream calls were not dispatched');
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: actual v2 envelope, fixed query, projection and privacy`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json(envelope());
},
},
hook,
);
assert.equal(calls.length, 0, 'construction and mounting do not acquire');
const result = await request(
boxUrl(0, '&url=https://attacker.example&per_page=999'),
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.rows[0].stableId, 'inat:1');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.scope, 'view');
assert.deepEqual(result.body.box, {
south: 47.5,
west: -122.5,
north: 47.7,
east: -122.3,
});
const query = calls[0].searchParams;
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v2/observations',
);
// Only the projected fields are requested, never user or identification data.
assert.match(query.get('fields'), /^\(id:!t,uri:!t,.*photos:\(url:!t/);
assert.doesNotMatch(query.get('fields'), /user|identifications/);
assert.equal(query.get('swlat'), '47.5');
assert.equal(query.get('swlng'), '-122.5');
assert.equal(query.get('nelat'), '47.7');
assert.equal(query.get('nelng'), '-122.3');
assert.equal(query.get('d1'), '2026-11-02');
assert.equal(query.has('created_d1'), false);
assert.equal(query.get('per_page'), '200');
assert.equal(query.get('verifiable'), 'true');
assert.equal(query.get('photos'), 'true');
assert.equal(query.get('captive'), 'false');
assert.equal(query.get('order_by'), 'random');
assert.equal(query.has('order'), false);
assert.doesNotMatch(
JSON.stringify(result.body),
/must-never-leave-server|"user"/,
);
assert.doesNotMatch(
JSON.stringify([...cache.values()]),
/must-never-leave-server|"user"/,
);
});
}
test('gate, method, route and query validation run before quota, cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 32; i += 1) {
const response = await request('/observations?scope=global', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
});
assert.equal(response.status, 403);
}
const method = await request('/observations', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/observations/extra', '//[invalid'])
assert.equal((await request(route)).body.error, 'unknown_route');
assert.equal(
(await request('/observations?scope=planet')).body.error,
'invalid_scope',
);
for (const query of [
'',
'south=NaN&west=0&north=1&east=1',
'south=&west=0&north=1&east=1',
'south=0&west=0&north=0&east=1',
'south=0&west=1&north=1&east=0',
'south=-91&west=0&north=1&east=1',
'south=0&west=-181&north=1&east=1',
'south=0&west=0&north=41&east=1',
'south=0&west=0&north=1&east=41',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_bbox', query);
}
assert.equal(cache.size, 0);
assert.equal(calls, 0);
const allowed = await request('/observations?scope=global', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(allowed.status, 200);
});
test('cached requests never exhaust a local request admission limiter', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 30; i += 1) assert.equal((await request()).status, 200);
const cached = await request();
assert.equal(cached.status, 200);
assert.equal(cached.headers['Retry-After'], undefined);
assert.equal(calls, 1);
});
test('proxy rejects oversized snapped spans before acquisition and preserves canonical client boxes', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
for (const query of [
'south=10.2&west=0&north=50.1&east=1',
'south=0&west=10.2&north=1&east=50.1',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400);
assert.equal(result.body.error, 'invalid_bbox');
}
assert.equal(urls.length, 0);
const canonical =
'/observations?south=47.4&west=-122.3&north=47.6&east=-122.1';
const first = await request(canonical);
assert.equal(first.status, 200);
assert.deepEqual(first.body.box, {
south: 47.4,
west: -122.3,
north: 47.6,
east: -122.1,
});
assert.equal(urls[0].searchParams.get('swlat'), '47.4');
assert.equal(urls[0].searchParams.get('swlng'), '-122.3');
const second = await request(canonical);
assert.deepEqual(second.body, first.body);
assert.equal(urls.length, 1, 'canonical boxes share their cache key');
});
test('view snapping, whales, research and global queries obey the requested policy', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
await request(
'/observations?south=40.2&west=-123.7&north=46.1&east=-121.4&quality=research&taxa=Aves',
);
assert.equal(urls[0].searchParams.get('swlat'), '40');
assert.equal(urls[0].searchParams.get('nelat'), '47');
assert.equal(urls[0].searchParams.get('per_page'), '200');
assert.equal(urls[0].searchParams.get('quality_grade'), 'research');
assert.equal(urls[0].searchParams.get('iconic_taxa'), 'Aves');
await request(boxUrl(0, '&taxa=whales'));
const whales = urls[1].searchParams;
assert.equal(whales.get('taxon_id'), '152871');
assert.equal(whales.get('d1'), '2026-10-28');
assert.equal(whales.has('created_d1'), false);
assert.equal(whales.has('photos'), false);
assert.equal(whales.has('iconic_taxa'), false);
await request('/observations?scope=global&quality=typo&taxa=typo&south=bad');
const global = urls[2].searchParams;
for (const field of [
'swlat',
'swlng',
'nelat',
'nelng',
'quality_grade',
'iconic_taxa',
])
assert.equal(global.has(field), false, field);
assert.equal(global.get('per_page'), '200');
assert.equal(global.get('d1'), '2026-11-02');
assert.equal(global.has('created_d1'), false);
assert.equal(global.get('order_by'), 'observed_on');
assert.equal(global.get('order'), 'desc');
await request('/observations?scope=global&taxa=whales');
const globalWhales = urls[3].searchParams;
assert.equal(globalWhales.get('d1'), '2026-10-28');
assert.equal(globalWhales.has('created_d1'), false);
assert.equal(globalWhales.get('order_by'), 'observed_on');
assert.equal(globalWhales.get('order'), 'desc');
});
test('fresh cache hits and global ten-minute TTL spend no upstream quota', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 299_999;
assert.deepEqual((await request()).body, first.body);
at += 1;
await request();
assert.equal(calls, 2);
await request('/observations?scope=global');
at += 599_999;
await request('/observations?scope=global');
assert.equal(calls, 3);
at += 1;
await request('/observations?scope=global');
assert.equal(calls, 4);
});
test('the same snapped view and window hour returns one cached random sample until expiry', async () => {
let at = NOW;
let draws = 0;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async (url) => {
const query = new URL(url).searchParams;
assert.equal(query.get('order_by'), 'random');
assert.equal(query.get('per_page'), '200');
return Response.json(envelope([observation(++draws)]));
},
});
const first = await request();
for (const elapsed of [1, 1000, 60_000, 299_999]) {
at = NOW + elapsed;
const next = await request();
assert.deepEqual(next.body, first.body);
}
assert.equal(draws, 1);
assert.match(cache.keys().next().value, /2026-11-02T18:00:00Z$/);
at = NOW + 300_000;
const refresh = await request();
assert.equal(refresh.body.rows[0].stableId, 'inat:2');
assert.equal(draws, 2);
});
test('disconnected queued views are dropped while a remaining coalesced consumer keeps its request', async () => {
const calls = [];
const releases = [];
const cache = new Map();
const request = install({
cache,
fetchImpl: (url, { signal }) => {
calls.push({ url: new URL(url), signal });
return new Promise((resolve) => releases.push(resolve));
},
});
const first = request(boxUrl(0));
await until(() => releases.length === 1);
const superseded = new AbortController();
const oldView = request(boxUrl(1), { signal: superseded.signal });
const oneConsumer = new AbortController();
const one = request(boxUrl(2), { signal: oneConsumer.signal });
const other = request(boxUrl(2));
superseded.abort();
oneConsumer.abort();
await Promise.all([oldView, one]);
// Rejoining the abandoned key creates a healthy generation; the old
// coalescing promise must not later delete or cancel this replacement.
const replacement = request(boxUrl(1));
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
assert.equal(calls[1].url.searchParams.get('swlng'), '-122.1');
assert.equal(calls[1].signal.aborted, false);
releases[1](Response.json(envelope([observation(2)])));
await until(() => releases.length === 3);
assert.equal(calls[2].url.searchParams.get('swlng'), '-122.3');
releases[2](Response.json(envelope([observation(3)])));
const results = await Promise.all([first, other, replacement]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(
calls.length,
3,
'the abandoned queued generation never dispatched',
);
assert.equal(cache.size, 3);
assert.equal(results[1].body.rows[0].stableId, 'inat:2');
assert.equal(results[2].body.rows[0].stableId, 'inat:3');
});
test('observation windows reject old sightings uploaded today and keep recent observations', async () => {
for (const [taxa, recentTime, expectedD1] of [
['all', '2026-11-04T17:00:00Z', '2026-11-02'],
['whales', '2026-10-30T17:00:00Z', '2026-10-28'],
]) {
let upstreamQuery;
const request = install({
fetchImpl: async (url) => {
upstreamQuery = new URL(url).searchParams;
return Response.json(
envelope([
{
...observation(80),
time_observed_at: '2010-06-01T12:00:00Z',
observed_on: '2010-06-01',
},
{
...observation(81),
time_observed_at: recentTime,
observed_on: recentTime.slice(0, 10),
},
]),
);
},
});
const result = await request(boxUrl(0, `&taxa=${taxa}`));
assert.equal(result.status, 200, taxa);
assert.equal(upstreamQuery.get('d1'), expectedD1);
assert.equal(upstreamQuery.has('created_d1'), false);
assert.equal(upstreamQuery.get('order_by'), 'random');
assert.deepEqual(
result.body.rows.map((row) => row.stableId),
['inat:81'],
);
}
});
test('same-key requests single-flight and a seventeenth distinct in-flight key queues', async () => {
const releases = [];
const request = install({
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const a = request();
const b = request();
await until(() => releases.length === 1);
releases.shift()(Response.json(envelope()));
assert.deepEqual((await a).body, (await b).body);
const pending = Array.from({ length: 16 }, (_, i) => request(boxUrl(i + 1)));
await until(() => releases.length === 1);
pending.push(request(boxUrl(18)));
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i](Response.json(envelope()));
}
assert.ok((await Promise.all(pending)).every(({ status }) => status === 200));
});
test('a fresh global sample survives the hourly observation-window rollover without another fetch', async () => {
let at = Date.parse('2026-11-04T18:59:00Z');
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request('/observations?scope=global');
at += 60_000;
const nextHour = await request('/observations?scope=global');
assert.deepEqual(nextHour.body, first.body);
assert.equal(nextHour.body.stale, undefined);
assert.equal(calls, 1);
at += 9 * 60_000;
await request('/observations?scope=global');
assert.equal(calls, 2);
});
test('pending global refresh coalesces across the hourly observation-window rollover with a busy queue', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: (url) =>
new Promise((resolve) =>
releases.push({
resolve,
url: new URL(url),
}),
),
});
const first = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
const views = Array.from({ length: 15 }, (_, i) => request(boxUrl(i)));
await until(() => releases.length === 1);
assert.equal(releases[0].url.searchParams.get('d1'), '2026-11-02');
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the same global query joins the pending refresh',
);
const distinct = request(
'/observations?scope=global&quality=research&taxa=Mammalia',
);
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i].resolve(Response.json(envelope()));
}
const [original, joined] = await Promise.all([first, nextHour]);
assert.equal(original.status, 200);
assert.equal((await distinct).status, 200);
assert.deepEqual(joined.body, original.body);
assert.ok((await Promise.all(views)).every(({ status }) => status === 200));
const globalEntries = [...cache.entries()].filter(
([, entry]) => entry.value.scope === 'global',
);
assert.equal(globalEntries.length, 2);
assert.match(globalEntries[0][0], /2026-11-02T18:00:00Z$/);
assert.equal(
(await request('/observations?scope=global&quality=research&taxa=Aves'))
.status,
200,
);
assert.equal(
releases.length,
17,
'the accepted global answer keeps its ten-minute freshness',
);
});
test('view refreshes retain distinct hourly keys while a previous-hour request is pending', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const request = install({
now: () => at,
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
await until(() => releases.length === 1);
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request();
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the next view queues behind the current call',
);
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
releases[1](Response.json(envelope()));
assert.ok(
(await Promise.all([first, nextHour])).every(
({ status }) => status === 200,
),
);
});
test('upstream minute ceiling waits for request 61 across client addresses', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
queueOptions: {
now: () => at,
intervalMs: 0,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 60; i += 1)
assert.equal(
(await request(boxUrl(i), { peer: `10.0.0.${i}` })).status,
200,
);
const queued = await request(boxUrl(60), { peer: '10.0.1.1' });
assert.equal(queued.status, 200);
assert.equal(queued.headers['Retry-After'], undefined);
assert.equal(at, NOW + 60_000);
assert.equal(calls, 61);
});
test('views refresh after many prior requests without fabricating an upstream limit', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 300_001;
for (let i = 1; i <= 60; i += 1)
await request(boxUrl(i), { peer: `10.0.0.${i}` });
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, undefined);
assert.notEqual(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(calls, 62);
});
test('bounded samples preserve partial metadata, and well-formed empty views are valid', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () =>
fail
? new Response('unavailable', { status: 503 })
: Response.json(envelope([observation()], 500)),
});
const first = await request();
assert.equal(first.body.partial, true);
assert.equal(first.body.rawRowCount, 500);
fail = true;
at += 59 * 60_000;
const stale = await request();
assert.equal(
stale.status,
200,
'hourly window changes still permit stale fallback',
);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(stale.body.partial, true);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
const empty = install({
fetchImpl: async () => Response.json(envelope([], 0)),
});
const result = await empty();
assert.equal(result.status, 200);
assert.deepEqual(result.body.rows, []);
});
test('bad envelopes, JSON and capped bodies fail closed without inventing a cooldown', async () => {
for (const bad of [
() => Response.json({ total_results: 1, results: {} }),
() => Response.json({ total_results: 1, observations: [observation()] }),
() => Response.json({ total_results: '1', results: [observation()] }),
() => Response.json(envelope([], 1)),
() => Response.json(envelope([{ ...observation(), id: null }], 1)),
() => new Response('{malformed'),
() =>
new Response('{}', {
headers: { 'Content-Length': String(8 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json(envelope());
},
});
const first = await request();
assert.equal(first.status, 502);
assert.equal(first.body.error, 'inaturalist_unavailable');
assert.equal(first.headers['Retry-After'], undefined);
assert.equal((await request(boxUrl(1))).status, 200);
assert.equal(calls, 2);
}
});
test('a wholly invalid iNat page cannot replace a previously valid snapshot', async () => {
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () =>
Response.json(
fail
? envelope([{ ...observation(), quality_grade: 'casual' }])
: envelope(),
),
});
const first = await request();
const original = cache.values().next().value;
fail = true;
at += 300_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(cache.values().next().value, original);
});
test('429 cooldown honors seconds, HTTP dates, and long upstream deadlines', async () => {
assert.equal(inatRetryCooldownMs(null, NOW), 60_000);
assert.equal(inatRetryCooldownMs('0', NOW), 0);
assert.equal(inatRetryCooldownMs('2', NOW), 2000);
assert.equal(inatRetryCooldownMs('999999', NOW), 999_999_000);
assert.equal(inatRetryCooldownMs('7200', NOW), 7_200_000);
assert.equal(
inatRetryCooldownMs(new Date(NOW + 7_200_000).toUTCString(), NOW),
7_200_000,
);
let at = NOW;
const releases = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
const later = request(boxUrl(1));
await until(() => releases.length === 1);
releases[0](
new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
}),
);
assert.equal((await first).headers['Retry-After'], '999999');
assert.equal((await later).headers['Retry-After'], '999999');
at += 1000;
assert.equal((await request(boxUrl(1))).headers['Retry-After'], '999998');
at += 5000;
const cooling = await request(boxUrl(2));
assert.equal(cooling.status, 429);
assert.equal(cooling.headers['Retry-After'], '999993');
assert.equal(
releases.length,
1,
'queued calls do not dispatch during cooldown',
);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
0,
);
});
for (const [upstreamStatus, expectedStatus, code, retryAfterS] of [
[429, 429, 'inaturalist_rate_limited', 120],
[503, 502, 'inaturalist_unavailable', 120],
]) {
test(`concurrent reservations honor an immediate upstream ${upstreamStatus} before dispatch`, async () => {
let calls = 0;
const warnings = [];
const client = createInatClient({
now: () => 0,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('synthetic failure', {
status: upstreamStatus,
headers: { 'Retry-After': '120' },
});
},
});
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '1969-12-30',
};
const results = await Promise.allSettled(
Array.from({ length: 3 }, () => client.getObservations(params)),
);
assert.equal(
calls,
1,
'pending reservations must not dispatch during cooldown',
);
for (const result of results) {
assert.equal(result.status, 'rejected');
assert.equal(result.reason.status, expectedStatus);
assert.equal(result.reason.code, code);
assert.equal(result.reason.retryAfterS, retryAfterS);
}
assert.equal(
warnings.length,
1,
'only the dispatched refresh logs a failure',
);
await assert.rejects(client.getObservations(params), {
status: expectedStatus,
code,
retryAfterS,
});
assert.equal(calls, 1);
});
}
test('budget is read lazily, persisted as counters only, enforced across restart and UTC rollover', async (t) => {
const parent = await mkdtemp(path.join(os.tmpdir(), 'gev-inat-budget-'));
t.after(() => rm(parent, { recursive: true, force: true }));
const cacheDir = path.join(parent, 'not-created');
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '2';
t.after(() =>
before === undefined
? delete process.env.INATURALIST_DAILY_BUDGET
: (process.env.INATURALIST_DAILY_BUDGET = before),
);
let at = NOW;
let calls = 0;
const options = {
now: () => at,
cacheDir,
warn() {},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
};
const client = createInatClient(options);
assert.deepEqual(
await readdir(parent),
[],
'construction performs no directory write',
);
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '2026-11-02',
};
await Promise.all([
client.getObservations(params),
client.getObservations(params),
]);
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-04', count: 2 },
);
assert.deepEqual(
await readdir(cacheDir),
['budget.json'],
'atomic temporary files are retired',
);
const restarted = createInatClient(options);
await assert.rejects(restarted.getObservations(params), {
status: 503,
code: 'inaturalist_budget_exhausted',
});
assert.equal(calls, 2);
at = Date.parse('2026-11-05T00:00:00Z');
await restarted.getObservations(params);
assert.deepEqual(restarted.budgetState(), {
date: '2026-11-05',
count: 1,
budget: 2,
});
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-05', count: 1 },
);
assert.equal(calls, 3);
});
test('memory cache is an LRU bounded to 400 projected entries', async () => {
let at = NOW;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(envelope()),
});
for (let i = 0; i < 400; i += 1) {
if (i && i % 20 === 0) at += 61_000;
await request(boxUrl(i), { peer: String(i) });
}
assert.equal(cache.size, 400);
const firstKey = cache.keys().next().value;
const secondKey = [...cache.keys()][1];
await request(boxUrl(0));
await request(boxUrl(400));
assert.equal(cache.size, 400);
assert.equal(cache.has(firstKey), true);
assert.equal(cache.has(secondKey), false);
});
test('aborted client calls spend no budget and never fetch', async () => {
const controller = new AbortController();
controller.abort();
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
fetchImpl: () => assert.fail('aborted call fetched'),
});
await assert.rejects(
client.getObservations({}, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(client.budgetState().count, 0);
});
@@ -1,70 +0,0 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { inaturalistProxy } from './observations.js';
export const NOW = Date.parse('2026-11-04T18:05:00Z');
/** Mount the proxy over synthetic HTTP objects, without opening a socket. */
export function installInatProxy(
defaultUrl,
options = {},
hook = 'configureServer',
) {
let handler;
const plugin = inaturalistProxy({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
...options,
});
assert.equal(plugin.name, 'inaturalist');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/inaturalist');
handler = callback;
},
},
});
return async (
url = defaultUrl,
{ method = 'GET', peer = 'local', headers = {}, signal } = {},
) => {
const res = Object.assign(new EventEmitter(), {
headers: {},
writeHead(status, headers) {
this.status = status;
Object.assign(this.headers, headers);
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
});
const abort = () => {
res.destroyed = true;
res.emit('close');
};
signal?.addEventListener('abort', abort, { once: true });
try {
await handler(
Object.assign(new EventEmitter(), {
url,
method,
headers,
socket: { remoteAddress: peer },
}),
res,
);
} finally {
signal?.removeEventListener('abort', abort);
}
return res;
};
}
-109
View File
@@ -1,109 +0,0 @@
import { setTimeout as delay } from 'node:timers/promises';
// A four-request burst keeps a cold view responsive; thereafter every provider
// call shares the same one-per-second token supply. The rolling minute ceiling
// queues the few requests a token bucket alone could allow above 60/min.
const INAT_INTERVAL_MS = 1000;
const INAT_BURST = 4;
const INAT_WINDOW_MS = 60_000;
const INAT_MAX_PER_WINDOW = 60;
const INAT_MAX_IN_FLIGHT = 4;
/** Observation queries outrank tiles waiting for a token. Aborted consumers
* leave the queue before an attempt or daily-budget reservation is spent. */
export function createInatQueue({
now = () => performance.now(),
wait = (ms) => delay(ms),
intervalMs = INAT_INTERVAL_MS,
burst = INAT_BURST,
windowMs = INAT_WINDOW_MS,
maxInFlight = INAT_MAX_IN_FLIGHT,
} = {}) {
const pending = [];
const dispatchedAt = [];
let tokens = burst;
let refilledAt = null;
let running = false;
let inFlight = 0;
let slotFreed = null;
function refill() {
const at = now();
if (refilledAt !== null && intervalMs > 0)
tokens = Math.min(
burst,
tokens + Math.max(0, at - refilledAt) / intervalMs,
);
if (intervalMs <= 0) tokens = burst;
refilledAt = at;
}
function schedule() {
if (running) return;
running = true;
// A microtask also lets same-turn observation queries take priority.
void Promise.resolve().then(drain);
}
async function drain() {
try {
while (pending.length) {
if (inFlight >= maxInFlight) {
await new Promise((resolve) => (slotFreed = resolve));
continue;
}
refill();
const at = now();
while (dispatchedAt.length && dispatchedAt[0] <= at - windowMs)
dispatchedAt.shift();
const tokenWait = tokens < 1 ? (1 - tokens) * intervalMs : 0;
const windowWait =
windowMs > 0 && dispatchedAt.length >= INAT_MAX_PER_WINDOW
? dispatchedAt[0] + windowMs - at
: 0;
if (tokenWait > 0 || windowWait > 0) {
await wait(Math.ceil(Math.max(tokenWait, windowWait)));
continue;
}
// Stable sorting preserves FIFO order within each priority.
pending.sort((a, b) => a.priority - b.priority);
const job = pending.shift();
job.signal?.removeEventListener('abort', job.abort);
if (job.signal?.aborted) {
job.reject(job.signal.reason);
continue;
}
tokens -= 1;
if (windowMs > 0) dispatchedAt.push(at);
inFlight += 1;
// A rejected task releases its slot without stopping the queue.
void new Promise((resolve) => resolve(job.task()))
.then(job.resolve, job.reject)
.finally(() => {
inFlight -= 1;
const wake = slotFreed;
slotFreed = null;
wake?.();
});
}
} finally {
running = false;
}
}
return function enqueue(task, { signal, priority = 1 } = {}) {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const job = { task, signal, priority, resolve, reject };
job.abort = () => {
const index = pending.indexOf(job);
if (index >= 0) pending.splice(index, 1);
signal.removeEventListener('abort', job.abort);
reject(signal.reason);
};
signal?.addEventListener('abort', job.abort, { once: true });
pending.push(job);
schedule();
});
};
}
-111
View File
@@ -1,111 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatQueue } from './queue.js';
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('queue did not reach its expected state');
}
test('shared governor paces a four-call burst within the rolling minute ceiling', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
now: () => at,
wait: async (ms) => {
waits.push(ms);
at += ms;
},
});
const dispatched = [];
await Promise.all(
Array.from({ length: 65 }, () => queue(() => dispatched.push(at))),
);
assert.equal(dispatched.length, 65);
assert.deepEqual(dispatched.slice(0, 7), [0, 0, 0, 0, 1000, 2000, 3000]);
assert.deepEqual(waits.slice(0, 3), [1000, 1000, 1000]);
for (const time of dispatched)
assert.ok(
dispatched.filter((other) => other > time - 60_000 && other <= time)
.length <= 60,
);
assert.equal(dispatched[60], 60_000);
});
test('an observation arriving during the pacing wait overtakes queued tiles', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
burst: 1,
now: () => at,
wait: (ms) => new Promise((resolve) => waits.push({ ms, resolve })),
});
const dispatched = [];
await queue(() => dispatched.push('first tile'));
const tile = queue(() => dispatched.push('waiting tile'));
await until(() => waits.length === 1);
const observation = queue(() => dispatched.push('observation'), {
priority: 0,
});
at += waits[0].ms;
waits[0].resolve();
await observation;
assert.deepEqual(dispatched, ['first tile', 'observation']);
await until(() => waits.length === 2);
at += waits[1].ms;
waits[1].resolve();
await tile;
assert.deepEqual(dispatched, ['first tile', 'observation', 'waiting tile']);
});
test('superseded tasks leave before dispatch, and a rejection cannot poison the tail', async () => {
const queue = createInatQueue({ intervalMs: 0 });
const dispatched = [];
let release;
const first = queue(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
await until(() => release);
const controller = new AbortController();
const superseded = queue(() => assert.fail('superseded tile dispatched'), {
signal: controller.signal,
});
const dropped = assert.rejects(superseded, { name: 'AbortError' });
controller.abort();
await dropped;
const failed = queue(() => {
dispatched.push('failed');
throw new Error('synthetic');
});
const failure = assert.rejects(failed, /synthetic/);
const succeeding = queue(() => dispatched.push('recovered'));
release();
await Promise.all([first, failure, succeeding]);
assert.deepEqual(dispatched, ['failed', 'recovered']);
});
test('a slow call does not hold later calls; at most four run at once', async () => {
const queue = createInatQueue({ intervalMs: 0, windowMs: 0 });
const releases = [];
const started = [];
const slow = (id) => () =>
new Promise((resolve) => {
started.push(id);
releases.push(() => resolve(id));
});
const jobs = [1, 2, 3, 4, 5].map((id) => queue(slow(id)));
await until(() => started.length === 4);
assert.deepEqual(started, [1, 2, 3, 4], 'four start without waiting');
releases[1]();
assert.equal(await jobs[1], 2, 'a later call finishes before the first');
await until(() => started.length === 5);
assert.deepEqual(started, [1, 2, 3, 4, 5], 'a freed slot starts the next');
for (const release of releases) release();
assert.deepEqual(await Promise.all(jobs), [1, 2, 3, 4, 5]);
});
-218
View File
@@ -1,218 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { syntheticGrid } from './grid.fixture.mjs';
import { INAT_GRID_DECODE_MEASURE } from './grid.js';
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves';
const params = {
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
};
const tick = () => new Promise(setImmediate);
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('tile cache lasts fifteen minutes, then returns stale before revalidation finishes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: () => {
calls += 1;
return calls === 1
? Response.json(syntheticGrid())
: new Promise((resolve) => {
release = resolve;
});
},
});
const first = await request();
at += 899_999;
const freshPromise = request();
await tick();
assert.equal(calls, 1, 'a fresh tile never spends another upstream attempt');
assert.deepEqual((await freshPromise).body, first.body);
at += 1;
const stalePromise = request();
let resolved = false;
void stalePromise.then(() => {
resolved = true;
});
await tick();
try {
assert.equal(
resolved,
true,
'stale cells publish while refresh is pending',
);
const stale = await stalePromise;
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.deepEqual(stale.body.cells, first.body.cells);
await request();
assert.equal(calls, 2, 'all stale consumers share one background refresh');
const refreshPromise = request(`${gridUrl}&refresh=wait`);
let refreshed = false;
void refreshPromise.then(() => {
refreshed = true;
});
await tick();
assert.equal(
refreshed,
false,
'a background consumer can await the same refresh',
);
release(Response.json(syntheticGrid()));
const fresh = await refreshPromise;
assert.equal(fresh.body.stale, undefined);
assert.equal(fresh.body.fetchedAt, at);
assert.equal(
calls,
2,
'waiting for fresh cells never duplicates upstream work',
);
release = null;
} finally {
release?.(Response.json(syntheticGrid()));
}
});
test('more than sixteen queued views and thirty local requests never report local rate limiting', async () => {
let release;
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json(syntheticGrid());
},
});
const pending = Array.from({ length: 40 }, (_, x) =>
request(`/grid?z=6&x=${x}&y=1`),
);
await tick();
release();
const results = await Promise.all(pending);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls, 40);
});
test('only real upstream 429 or 5xx failures start cooldown', async () => {
for (const bad of [
() => Promise.reject(new Error('network')),
() => Response.json({ malformed: true }),
() => new Response('', { status: 404 }),
]) {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
fetchImpl: () => {
calls += 1;
return calls === 1
? bad()
: Response.json({ results: [], total_results: 0 });
},
});
await assert.rejects(client.getObservations(params), { status: 502 });
await client.getObservations(params);
assert.equal(
calls,
2,
'a non-cooldown failure must not hold the next attempt',
);
}
});
test('actual upstream cooldown is marked separately from a local budget stop', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return new Response('', {
status: upstream,
headers: { 'Retry-After': '120' },
});
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '120');
await request();
assert.equal(calls, 1);
}
});
test('upstream Retry-After zero survives the HTTP response and permits an immediate next request', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return calls === 1
? new Response('', {
status: upstream,
headers: { 'Retry-After': '0' },
})
: Response.json(syntheticGrid());
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '0');
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
test('a local daily budget stop has a distinct code and no upstream cooldown marker', async (t) => {
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '1';
t.after(() => {
if (before === undefined) delete process.env.INATURALIST_DAILY_BUDGET;
else process.env.INATURALIST_DAILY_BUDGET = before;
});
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return Response.json(syntheticGrid());
},
});
assert.equal((await request()).status, 200);
const stopped = await request('/grid?z=2&x=0&y=1');
assert.equal(stopped.status, 503);
assert.equal(stopped.body.error, 'inaturalist_budget_exhausted');
assert.equal(stopped.headers['X-Upstream-Status'], undefined);
assert.equal((await request()).status, 200, 'cached data remains available');
assert.equal(calls, 1);
});
test('cached tiles avoid UTFGrid decode and decode timing entries stay bounded', async () => {
const request = install({
fetchImpl: () => Response.json(syntheticGrid()),
});
await request();
const sample = performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0];
assert.ok(sample.duration >= 0);
for (let i = 0; i < 10; i += 1) await request();
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0],
sample,
);
for (let x = 0; x < 4; x += 1) await request(`/grid?z=3&x=${x}&y=1`);
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE).length,
1,
);
});
-6
View File
@@ -28,9 +28,6 @@ import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js';
import { droneAirspaceProxy } from './droneAirspace.js';
import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -82,9 +79,6 @@ function localProviderPlugins({
firePerimetersProxy(),
gnssIntegrityProxy(),
ghostMapsProxy(),
droneAirspaceProxy(),
inaturalistProxy(),
acartiaProxy(),
keySetupEndpoint(),
];
}
-5
View File
@@ -1,4 +1,3 @@
import { createOpenAiChatHandler } from './openai/chat.js';
import { defaultSourceRoot } from './common/source-root.js';
import { handleHudSummary } from './openai/hud-summary.js';
import { createDebugLogHandler } from './openai/debug-log.js';
@@ -23,10 +22,6 @@ function openAiRealtimeProxy({
} = {}) {
const oauthLogin = createCodexOAuthLogin(realtime);
function install(middlewares) {
middlewares.use(
'/api/openai/chat',
createOpenAiChatHandler({ ...realtime, annotationGuidance }),
);
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
-169
View File
@@ -1,169 +0,0 @@
import { createTextChatHandler } from '../common/textChat.js';
import { preferCodexOAuth } from './codex-auth.js';
import { OPENAI_DEFAULT_PER_MIN } from './rate-limit.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
const callShape = (call) =>
object(call) &&
keys(call, ['id', 'type', 'function']) &&
typeof call.id === 'string' &&
call.id.length > 0 &&
call.id.length <= 1024 &&
call.type === 'function' &&
object(call.function) &&
keys(call.function, ['name', 'arguments']) &&
typeof call.function.name === 'string' &&
call.function.name.length <= 128 &&
typeof call.function.arguments === 'string';
function assistantShape(message) {
return (
object(message) &&
keys(message, ['role', 'content', 'tool_calls']) &&
message.role === 'assistant' &&
(message.content == null || typeof message.content === 'string') &&
(message.tool_calls === undefined ||
(Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0 &&
message.tool_calls.length <= 32 &&
message.tool_calls.every(callShape) &&
new Set(message.tool_calls.map((call) => call.id)).size ===
message.tool_calls.length))
);
}
function validMessages(messages) {
if (!Array.isArray(messages) || !messages.length || messages.length > 96)
return false;
let pending = [];
for (const message of messages) {
if (!object(message)) return false;
if (pending.length) {
if (
!keys(message, ['role', 'content', 'tool_call_id']) ||
message.role !== 'tool' ||
typeof message.content !== 'string' ||
message.tool_call_id !== pending.shift()
)
return false;
} else if (message.role === 'user') {
if (
!keys(message, ['role', 'content']) ||
typeof message.content !== 'string'
)
return false;
} else if (assistantShape(message)) {
pending = (message.tool_calls || []).map((call) => call.id);
} else return false;
}
return !pending.length;
}
export function createOpenAiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveApiKey = () => process.env.OPENAI_API_KEY,
resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini',
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
environment = process.env,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'openai',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: OPENAI_DEFAULT_PER_MIN,
// Chat can only bill the API key, which this operator lock forbids.
refuse() {
let error =
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH, and typed OpenAI chat needs an API key.';
try {
if (!preferCodexOAuth(environment)) return null;
} catch (invalid) {
error = invalid.message;
}
return { error, code: 'CODEX_OAUTH_REQUIRED' };
},
prepare(input) {
const model = String(resolveChatModel()).trim();
if (
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model) ||
/realtime|audio|tts/i.test(model)
)
return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['messages']) ||
!validMessages(input.messages)
)
return null;
const overpass = overpassConfigured();
return {
endpoint: 'https://api.openai.com/v1/chat/completions',
headers: (key) => ({ Authorization: `Bearer ${key}` }),
body: {
model,
n: 1,
stream: false,
store: false,
max_completion_tokens: 4096,
messages: [
{
role: 'system',
content: textInstructions(annotationGuidance, { overpass }),
},
...input.messages,
],
tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
type: 'function',
function: { name, description, parameters, strict: false },
}),
),
},
};
},
parse(data) {
const choice = data?.choices?.[0];
const raw = choice?.message;
if (!raw) return null;
const content = {
role: raw.role,
content: raw.content ?? null,
...(raw.tool_calls?.length ? { tool_calls: raw.tool_calls } : {}),
};
if (
!assistantShape(content) ||
(!content.content && !content.tool_calls?.length)
)
return null;
const calls = (content.tool_calls || []).map((call) => {
try {
return {
name: call.function.name,
args: JSON.parse(call.function.arguments),
};
} catch {
return { name: call.function.name, parseError: true };
}
});
return {
content,
finishReason: choice.finish_reason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
+13 -69
View File
@@ -1,12 +1,5 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import {
HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput,
hudSummaryInstructions,
hudSummaryIsUnspaced,
hudSummaryLocale,
HUD_SUMMARY_INSTRUCTIONS,
keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -25,58 +18,14 @@ function extractOpenAiResponseText(data) {
.trim();
}
/** Parse the summary and normalize the canonical feed state. */
function parseHudSummaryReply(text, locale) {
let reply = null;
try {
reply = JSON.parse(text);
} catch {
/* not JSON: no summary */
}
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState
: null;
return {
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
// Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
function toFiveWordHudSummary(value) {
return String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
.split(/\s+/)
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.filter(Boolean);
// Characters, not words: the model counts Rue de Rivoli as one word.
const max = hudSummaryIsUnspaced(locale)
? HUD_SUMMARY_MAX_UNSPACED_CHARS
: HUD_SUMMARY_MAX_CHARS;
return capAtWord(words.join(' '), max, locale);
}
/**
* Cut `text` to `max` characters at a word boundary; a first word longer
* than that is cut where the cap falls.
*/
function capAtWord(text, max, locale) {
const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
];
if (graphemes.length <= max) return text;
const cut = graphemes[max].index;
let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (index + segment.length > cut) break;
end = index + segment.length;
}
return text.slice(0, end).trim() || text.slice(0, cut);
.filter(Boolean)
.slice(0, 5)
.join(' ');
}
async function handleHudSummary(req, res) {
@@ -128,18 +77,14 @@ async function handleHudSummary(req, res) {
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: hudSummaryInstructions(context?.locale),
input: hudSummaryInput(context),
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
instructions: HUD_SUMMARY_INSTRUCTIONS,
input: JSON.stringify(context),
reasoning: { effort: 'minimal' },
max_output_tokens: 200,
max_output_tokens: 100,
}),
});
const data = await response.json().catch(() => ({}));
const { summary, feedState } = parseHudSummaryReply(
extractOpenAiResponseText(data),
context?.locale,
);
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@@ -148,7 +93,6 @@ async function handleHudSummary(req, res) {
res.end(
JSON.stringify({
summary: summary || null,
feedState,
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
@@ -166,4 +110,4 @@ async function handleHudSummary(req, res) {
}
}
export { handleHudSummary, parseHudSummaryReply };
export { handleHudSummary };
+8 -22
View File
@@ -1,14 +1,8 @@
const DEFAULT_ANNOTATION_GUIDANCE =
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
/**
* The Realtime session directives, one per line. `overpass` says an operator
* configured Overpass, which is what offers osm_query (see tools.js); without
* it the prompt never mentions the tool.
*/
function realtimeDirectives(
function realtimeInstructions(
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
{ overpass = false } = {},
) {
return [
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
@@ -27,20 +21,16 @@ function realtimeDirectives(
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.',
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names, or an areaId you already have; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId; to outline it pass draw=true; annotate_map is for marks, not boundaries. Count inside it → analyst_query kind=area with the areaId (or kind=region); "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a place → resolve_area, then find_imagery. Ambiguous name: call resolve_area; on needsClarification ask which, never guess.' +
(overpass
? ' "All hospitals/schools/… in <place>" → resolve_area, then osm_query; in view → osm_query alone; follow-ups → analyst_query layers [osm-places], scope anywhere.'
: ''),
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
'Do not mention disabled layers or stale selections.',
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
//
@@ -70,15 +60,11 @@ function realtimeDirectives(
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus or district, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park or district footprint; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus, district or boundary, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park, district or boundary outline; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
annotationGuidance,
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
];
].join('\n');
}
function realtimeInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options).join('\n');
}
export { realtimeInstructions, realtimeDirectives };
export { realtimeInstructions };
+3 -7
View File
@@ -13,8 +13,7 @@ import {
OPENAI_REALTIME_TRANSCRIBE_MODEL_DEFAULT,
} from './constants.js';
import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import { GEV_REALTIME_TOOLS } from './tools.js';
import {
preferCodexOAuth,
readCodexOAuthAccessToken,
@@ -137,8 +136,6 @@ function createRealtimeTokenHandler({
environment = process.env,
models = {},
tools = GEV_REALTIME_TOOLS,
// osm_query is offered only when an operator configured Overpass.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
} = {}) {
return async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
@@ -276,7 +273,6 @@ function createRealtimeTokenHandler({
transcribeModel.toLowerCase() === 'off'
? {}
: { transcription: { model: transcribeModel } };
const overpass = overpassConfigured();
const sessionConfig = {
session: {
type: 'realtime',
@@ -302,8 +298,8 @@ function createRealtimeTokenHandler({
},
output: { voice },
},
instructions: realtimeInstructions(annotationGuidance, { overpass }),
tools: offeredTools(tools, { overpass }),
instructions: realtimeInstructions(annotationGuidance),
tools,
tool_choice: 'auto',
},
};
+5 -48
View File
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
},
control_cockpit: {
description:
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
$position: 1,
parameters: {
properties: {
@@ -405,7 +405,7 @@ export const ACTION_DESCRIPTIONS = {
},
annotate_map: {
description:
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints and boundaries, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
$position: 1,
parameters: {
properties: {
@@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = {
properties: {
direction: {
description:
'Pan/rotate/tilt steps; orbit/rotate_globe default right.',
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
$position: 2,
},
mode: {
@@ -531,10 +531,6 @@ export const ACTION_DESCRIPTIONS = {
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
$position: 2,
},
pitchDeg: {
description: 'Absolute; -45 default, -89 top-down.',
$position: 1,
},
},
},
},
@@ -551,48 +547,9 @@ export const ACTION_DESCRIPTIONS = {
},
},
},
resolve_area: {
description:
'Named place → reusable areaId for analyst_query scope {kind:area} and other area tools. draw=true outlines it ("outline Bagmati province"); around=true is "an area around <landmark>": its enclosing mapped area, else a buffer marked approximate. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: retry once.',
$position: 1,
parameters: {
properties: {
query: {
description: 'The place as said, with any kind word.',
$position: 1,
},
level: {
description: 'Only when the user names the kind.',
$position: 1,
},
within: {
description: 'Country or state the user added.',
$position: 1,
},
},
},
},
find_imagery: {
description:
'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: leave it.',
$position: 1,
parameters: {
properties: {
maxCloudPct: {
description: '"cloud-free" → 10.',
$position: 1,
},
},
},
},
osm_query: {
description:
'OpenStreetMap places of one kind ("hospitals in Kathmandu"). areaId from resolve_area; omit for the view. Shows them in OSM Places with numbered referents; they become the last answer. countScope bounding-box means an approximate count. OSM_TIMEOUT: retry once.',
$position: 1,
},
analyst_query: {
description:
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), NEEDS_CLARIFICATION (ask which), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.',
$position: 1,
parameters: {
properties: {
@@ -603,7 +560,7 @@ export const ACTION_DESCRIPTIONS = {
},
scope: {
description:
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"); area + areaId from resolve_area; drawn = "the area I drew"; annotation = "the area you outlined". Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates.',
'Always set it (see WHERE). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"). Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates. There is no drawn-area scope yet: say so rather than answering for the view.',
$position: 2,
properties: {
name: {
-25
View File
@@ -1,7 +1,6 @@
import { createActionTools } from '../../../src/voice/actionSchemas.js';
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
/** Every voice tool, as the Realtime session describes them. */
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
/**
@@ -15,27 +14,3 @@ export function realtimeSessionTools(additional = []) {
...additional.filter((tool) => !names.has(tool.name)),
];
}
/** Tools that need an operator-configured Overpass (OVERPASS_UPSTREAMS). */
export const OVERPASS_ONLY_TOOLS = Object.freeze(['osm_query']);
/**
* The tools one session is offered. Bulk OpenStreetMap place search runs
* only on an operator's Overpass, so without one `osm_query` is left out
* rather than offered and refused.
* @param {Array<{name: string}>} tools
* @param {{overpass?: boolean}} [options]
*/
export function offeredTools(tools, { overpass = false } = {}) {
return overpass
? tools
: tools.filter((tool) => !OVERPASS_ONLY_TOOLS.includes(tool.name));
}
/**
* The app actions one session is offered.
* @param {{overpass?: boolean}} [options]
*/
export function realtimeTools({ overpass = false } = {}) {
return offeredTools(GEV_REALTIME_TOOLS, { overpass });
}
+9 -60
View File
@@ -1,12 +1,6 @@
import {
makeRateLimiter,
clientKey,
clientNetworkKey,
isLoopbackPeer,
} from './common/rate-limit.js';
import { hasProxySignals } from '../../src/localRequestGate.mjs';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { readRequestBodyCapped } from './common/request.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { admitSameSite } from './common/same-site.js';
import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
@@ -28,7 +22,6 @@ import {
overpassNotConfigured,
} from './overpass/transport.js';
import { installRouteMiddleware } from './places/routes.js';
import { installOsmFeaturesRoute } from './overpass/osmFeaturesRoute.js';
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
const _overpassInFlight = new Map();
@@ -36,21 +29,10 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0;
// Bound body-reading and validation work, including malformed requests and
// cache hits, before a byte is read. Remote peers share one global window so
// address rotation cannot mint admission without bound (IPv6 peers are keyed
// by /64 for the same reason). A direct loopback request — the owner's own
// browser and the repo QA harnesses, with no forwarding headers — has its own
// per-peer window outside that global, so busy LAN peers cannot lock the owner
// out of the map. A request relayed by a reverse proxy on this machine also
// arrives from loopback, but it is remote traffic: it is admitted from the
// remote pool under its own socket-keyed bucket, never from the owner's. The
// forwarded client address is not trusted as identity. Only admission is
// isolated: the upstream budget below (90/min per key, 300/min globally) is
// shared, so an owner request that misses cache can still be refused there.
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
const _overpassPeerAdmission = makeRateLimiter({
...OVERPASS_ADMISSION_WINDOW,
// cache hits. Keep the tighter upstream budget below independent of this gate.
const _overpassRequestLimiter = makeRateLimiter({
windowMs: 60_000,
max: 300,
globalMax: 900,
});
@@ -60,33 +42,6 @@ const _overpassRateLimiter = makeRateLimiter({
globalMax: 300,
});
/**
* Classify a request for admission. Direct loopback — a loopback socket with
* no forwarding header — is the owner's own pool; everything else, including a
* loopback socket that carries forwarding headers (a local reverse proxy), is
* remote. A proxied request keeps its socket identity under a `proxied:`
* prefix, so it never shares a bucket with the owner's direct requests.
* @param {import('http').IncomingMessage} req
* @returns {{direct: boolean, key: string}}
*/
function overpassAdmissionClass(req) {
const peer = clientNetworkKey(req);
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
if (hasProxySignals(req.headers || {}))
return { direct: false, key: `proxied:${peer}` };
return { direct: true, key: peer };
}
/**
* Spend one admission slot for this request; false when its bucket is over its
* window (or, for remote traffic, when the shared global window is full).
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
* @returns {boolean}
*/
function admitOverpassRequest({ direct, key }) {
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
}
/**
* Write a completed Overpass payload to the HTTP response.
*
@@ -148,13 +103,8 @@ function overpassProxy({ routing = {} } = {}) {
return;
}
// Keyless gate: refuses cross-site browser requests but, unlike the
// cost-bearing routes, admits the forwarding headers a reverse proxy
// adds — the layers that depend on Overpass must keep working for an
// instance deployed the way the README documents.
if (admitSameSiteKeyless(req, res)) return;
const admission = overpassAdmissionClass(req);
if (!admitOverpassRequest(admission)) {
if (admitSameSite(req, res)) return;
if (!_overpassRequestLimiter(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
@@ -213,7 +163,7 @@ function overpassProxy({ routing = {} } = {}) {
// requests; boundary-class queries keep their month-long TTL.
readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(admission.key),
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
loadUpstream: async () => {
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
return {
@@ -275,7 +225,6 @@ function overpassProxy({ routing = {} } = {}) {
});
installRouteMiddleware(server.middlewares, routing);
installOsmFeaturesRoute(server.middlewares);
};
return {
name: 'overpass-proxy',
-205
View File
@@ -1,205 +0,0 @@
import { OSM_PRESETS } from '../../../src/data/osmPresets.js';
/**
* Bounded, typed OpenStreetMap feature search: one preset from the curated
* table inside a bounding box.
*
* The request carries a preset id, a box, a mode and a limit — never query
* text. The server writes the Overpass query itself and bounds it: box size
* per mode (a count may cover more ground than a fetch), result count,
* Overpass timeout and memory, response bytes, and one query in flight. It
* runs only against the operator's `OVERPASS_UPSTREAMS`; the generic
* `/api/overpass` guard is untouched.
*
* There is deliberately no boundary (`area`) filter: Overpass servers differ
* in whether they hold area data, and one without it answers zero rather than
* failing. The browser filters the box's features by the exact area geometry
* instead, so a count inside a province is exact and a box count is labelled
* as a box count.
*/
export const OSM_FEATURE_LIMITS = Object.freeze({
/** Largest box a fetch may cover, in square degrees (~200 × 200 km at the equator). */
maxFetchDeg2: 4,
/** Largest box a count may cover. */
maxCountDeg2: 36,
/** Most features one fetch returns. */
maxLimit: 1000,
/** Overpass server-side timeout, seconds: it gives up before we stop waiting. */
timeoutS: 20,
/** Overpass server-side memory cap, bytes. */
maxsize: 64 * 1024 * 1024,
/** Response bytes read from the upstream. */
maxResponseBytes: 16 * 1024 * 1024,
/** Per-upstream wait: the Overpass timeout plus transfer time. */
upstreamTimeoutMs: 22_000,
/**
* The whole search, across every configured endpoint in turn. It ends
* before the voice answer's own deadline (`OSM_QUERY_LIMITS.budgetMs` in
* `src/voice/osmActions.js`, 25 s): a browser that gives up first closes
* the request, which cancels the upstream query, so nothing would be
* cached for the retry.
*/
upstreamBudgetMs: 22_000,
});
/** Tags a returned feature keeps, besides the preset's own. */
const KEPT_TAGS = [
'name',
'name:en',
'operator',
'brand',
'opening_hours',
'wheelchair',
'emergency',
'healthcare',
'addr:street',
'addr:city',
];
const quote = (text) =>
`"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
/**
* Parse and bound a request. Returns the compiled query or a refusal.
* @param {{preset: string, bbox: string|number[], mode?: string, limit?: string|number}} request
* @returns {{ok: true, ql: string, mode: string, preset: object, limit: number, boxes: number[][]}|{ok: false, code: string, error: string}}
*/
export function compileOsmFeatureQuery(request = {}) {
const refuse = (code, error) => ({ ok: false, code, error });
const presetId = String(request.preset || '');
const preset = Object.hasOwn(OSM_PRESETS, presetId)
? OSM_PRESETS[presetId]
: null;
if (!preset) return refuse('UNKNOWN_PRESET', 'Unknown feature kind.');
const mode = request.mode === 'count' ? 'count' : 'features';
const parts = (
Array.isArray(request.bbox)
? request.bbox
: String(request.bbox || '').split(',')
).map(Number);
if (parts.length !== 4 || !parts.every(Number.isFinite))
return refuse('BAD_BOX', 'bbox needs west,south,east,north.');
const [west, south, east, north] = parts;
if (
Math.abs(west) > 180 ||
Math.abs(east) > 180 ||
south < -90 ||
north > 90 ||
south >= north ||
west === east
)
return refuse('BAD_BOX', 'bbox edges are out of range.');
// A box crossing the antimeridian is searched as its two halves.
const boxes =
west < east
? [[south, west, north, east]]
: [
[south, west, north, 180],
[south, -180, north, east],
];
const deg2 = boxes.reduce((sum, [s, w, n, e]) => sum + (n - s) * (e - w), 0);
const cap =
mode === 'count'
? OSM_FEATURE_LIMITS.maxCountDeg2
: OSM_FEATURE_LIMITS.maxFetchDeg2;
if (deg2 > cap)
return refuse(
'AREA_TOO_LARGE',
mode === 'count'
? 'That area is too large to search; narrow it down.'
: 'That area is too large to list; count first or narrow it down.',
);
// An absent or empty limit is the default, not Number(null) = 0.
const requested =
request.limit === null ||
request.limit === undefined ||
String(request.limit).trim() === ''
? NaN
: Number(request.limit);
const limit = Math.max(
1,
Math.min(
OSM_FEATURE_LIMITS.maxLimit,
Number.isFinite(requested) ? Math.trunc(requested) : 500,
),
);
const selectors = [];
for (const [key, value] of preset.tags)
for (const [s, w, n, e] of boxes)
selectors.push(
`nwr[${quote(key)}=${quote(value)}](${s},${w},${n},${e});`,
);
const ql =
`[out:json][timeout:${OSM_FEATURE_LIMITS.timeoutS}][maxsize:${OSM_FEATURE_LIMITS.maxsize}];` +
`(${selectors.join('')});` +
(mode === 'count' ? 'out count;' : `out center tags ${limit};`);
return { ok: true, ql, mode, preset, limit, boxes };
}
/**
* Overpass JSON → the compact answer the browser gets.
* @param {object} payload Parsed Overpass JSON.
* @param {{mode: string, preset: object, limit: number}} compiled
*/
export function summarizeOsmFeatures(payload, compiled) {
if (!Array.isArray(payload?.elements))
throw new TypeError('Malformed Overpass feature response');
const elements = payload.elements;
if (compiled.mode === 'count') {
const tags = elements.find((el) => el?.type === 'count')?.tags;
const rawTotal = tags?.total;
const total = Number(rawTotal);
if (
!tags ||
!Object.hasOwn(tags, 'total') ||
!['string', 'number'].includes(typeof rawTotal) ||
String(rawTotal).trim() === '' ||
!Number.isSafeInteger(total) ||
total < 0
)
throw new TypeError('Malformed Overpass count response');
return {
mode: 'count',
preset: compiled.preset.id,
count: total,
nodes: Number(tags.nodes) || 0,
ways: Number(tags.ways) || 0,
relations: Number(tags.relations) || 0,
};
}
const features = [];
let unplaced = 0;
for (const el of elements) {
if (
!['node', 'way', 'relation'].includes(el?.type) ||
(!Number.isFinite(Number(el?.id)) && typeof el?.id !== 'string')
) {
unplaced += 1;
continue;
}
const lat = Number.isFinite(el?.lat) ? el.lat : el?.center?.lat;
const lon = Number.isFinite(el?.lon) ? el.lon : el?.center?.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
unplaced += 1;
continue;
}
const tags = {};
for (const key of [...KEPT_TAGS, ...compiled.preset.tags.map(([k]) => k)])
if (typeof el.tags?.[key] === 'string')
tags[key] = el.tags[key].slice(0, 120);
features.push({ id: `${el.type}/${el.id}`, lat, lon, tags });
}
// A genuinely empty `elements` array is authoritative. A non-empty answer
// whose every row is unusable is a schema failure, not "zero places"; do
// not let the route cache that as current state for a day.
if (elements.length && features.length === 0)
throw new TypeError('Overpass feature response has no usable rows');
return {
mode: 'features',
preset: compiled.preset.id,
count: features.length,
features,
truncated: elements.length >= compiled.limit,
...(unplaced ? { unplaced } : {}),
};
}
@@ -1,252 +0,0 @@
import path from 'node:path';
import { makeRateLimiter, clientNetworkKey } from '../common/rate-limit.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { createBoundedCache } from '../common/boundedCache.js';
import {
compileOsmFeatureQuery,
OSM_FEATURE_LIMITS,
summarizeOsmFeatures,
} from './osmFeatures.js';
import {
resolveOverpassUpstreams,
resolveOverpassUpstreamTag,
} from './constants.js';
import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js';
/** Places of interest change slowly: answers are kept a day. */
const OSM_FEATURES_TTL_MS = 86_400_000;
/** Upstream searches at once, across all clients (one per client). */
const OSM_MAX_RUNNING = 2;
/** Cache-key version; nothing cached under another version is reused. */
const CACHE_VERSION = 'v2';
/** Never simplify: these answers are points and counts, not drawings. */
const keepBody = (body) => body;
const jsonError = (status, error, extra = {}) => ({
status,
body: JSON.stringify({ error, ...extra }),
});
/**
* One upstream Overpass query at a time, with identical requests sharing it
* and answers in a bounded memory + disk cache.
*/
function createOverpassAnswerer({
fetchPayload,
cache,
maxResponseBytes,
upstreamTimeoutMs,
upstreamBudgetMs,
maxRunning = OSM_MAX_RUNNING,
}) {
const inFlight = new Map();
// One search per client, a few in all: one client can't hold the route.
const runningBy = new Map();
let running = 0;
return async function answer(key, ql, summarize, signal = null, owner = '') {
signal?.throwIfAborted?.();
const held = await cache.get(key);
signal?.throwIfAborted?.();
if (typeof held === 'string')
return { status: 200, body: held, cache: 'HIT' };
let operation = inFlight.get(key);
if (operation?.controller.signal.aborted) {
inFlight.delete(key);
operation = null;
}
if (!operation) {
if (runningBy.get(owner) || running >= maxRunning)
return {
...jsonError(
503,
runningBy.get(owner)
? 'Your previous map search is still running — try again shortly.'
: 'Another map search is running — try again shortly.',
),
retryAfter: '3',
};
running += 1;
runningBy.set(owner, (runningBy.get(owner) || 0) + 1);
const controller = new AbortController();
// The whole search, every configured endpoint in turn, gets one budget.
const budget = AbortSignal.timeout(upstreamBudgetMs);
operation = { controller, waiters: 0, settled: false, promise: null };
const owned = operation;
owned.promise = (async () => {
try {
const payload = await fetchPayload(
`data=${encodeURIComponent(ql)}`,
maxResponseBytes,
{
simplify: keepBody,
timeoutMs: upstreamTimeoutMs,
signal: AbortSignal.any([controller.signal, budget]),
},
);
if (!overpassPayloadIsData(payload))
return {
...jsonError(
payload?.rateLimited ? 429 : 503,
'OpenStreetMap search is unavailable right now.',
),
retryAfter: '10',
};
let parsed;
try {
parsed = JSON.parse(payload.body);
} catch {
return jsonError(
502,
'OpenStreetMap returned an unreadable answer.',
);
}
const body = JSON.stringify(summarize(parsed));
controller.signal.throwIfAborted();
await cache.set(key, body);
controller.signal.throwIfAborted();
return { status: 200, body, cache: 'MISS' };
} catch {
return budget.aborted && !controller.signal.aborted
? jsonError(504, 'OpenStreetMap took too long to answer.')
: jsonError(502, 'OpenStreetMap search failed.');
} finally {
owned.settled = true;
running -= 1;
const left = (runningBy.get(owner) || 1) - 1;
if (left > 0) runningBy.set(owner, left);
else runningBy.delete(owner);
if (inFlight.get(key) === owned) inFlight.delete(key);
}
})();
inFlight.set(key, owned);
}
operation.waiters += 1;
let abort = null;
const abandoned = signal
? new Promise((_, reject) => {
abort = () => {
operation.waiters -= 1;
if (operation.waiters === 0 && !operation.settled)
operation.controller.abort();
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
})
: null;
try {
return await (abandoned
? Promise.race([operation.promise, abandoned])
: operation.promise);
} finally {
if (signal && abort) signal.removeEventListener('abort', abort);
if (!signal?.aborted) operation.waiters -= 1;
}
};
}
function sender(res) {
return ({ status, body, cache, retryAfter }) => {
if (res.writableEnded) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(cache ? { 'X-OSM-Cache': cache } : {}),
...(retryAfter ? { 'Retry-After': retryAfter } : {}),
});
res.end(body);
};
}
/**
* Install `GET /api/osm/features?preset=&bbox=w,s,e,n[&mode=count][&limit=]`:
* one curated preset inside a box, answered by the operator's Overpass
* (`OVERPASS_UPSTREAMS`). With none configured the route answers
* `OVERPASS_NOT_CONFIGURED` and fetches nothing; public Nominatim is never a
* substitute for bulk place search.
*
* @param {{use: Function}} middlewares
* @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean, upstreamTag?: () => string}} [deps]
*/
export function installOsmFeaturesRoute(
middlewares,
{
fetchPayload = fetchOverpassPayload,
configured = () => resolveOverpassUpstreams().length > 0,
// Keys each answer to the instance that gave it, as the other Overpass
// caches do, so repointing OVERPASS_UPSTREAMS never serves the old one's.
upstreamTag = resolveOverpassUpstreamTag,
upstreamBudgetMs = OSM_FEATURE_LIMITS.upstreamBudgetMs,
cacheRoot = path.join(process.cwd(), '.gev-cache'),
featuresCache = createBoundedCache({
dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null,
ttlMs: OSM_FEATURES_TTL_MS,
maxMemoryEntries: 60,
maxDiskEntries: 400,
maxDiskBytes: 256 * 1024 * 1024,
}),
} = {},
) {
const limiter = makeRateLimiter({ windowMs: 60_000, max: 12, globalMax: 30 });
const features = createOverpassAnswerer({
fetchPayload,
cache: featuresCache,
maxResponseBytes: OSM_FEATURE_LIMITS.maxResponseBytes,
upstreamTimeoutMs: OSM_FEATURE_LIMITS.upstreamTimeoutMs,
upstreamBudgetMs,
});
middlewares.use('/api/osm/features', async (req, res) => {
const send = sender(res);
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
// A page elsewhere must not spend the operator's Overpass. The keyless
// gate, as on /api/overpass: voice works behind a reverse proxy too.
if (admitSameSiteKeyless(req, res)) return undefined;
if (!configured())
return send(
jsonError(503, 'Place search needs a configured Overpass server.', {
code: 'OVERPASS_NOT_CONFIGURED',
retryable: false,
}),
);
const url = new URL(req.url || '', 'http://localhost');
const compiled = compileOsmFeatureQuery({
preset: url.searchParams.get('preset'),
bbox: url.searchParams.get('bbox'),
mode: url.searchParams.get('mode'),
limit: url.searchParams.get('limit'),
});
if (!compiled.ok)
return send(jsonError(400, compiled.error, { code: compiled.code }));
// IPv6 peers by /64, as /api/overpass does: rotating addresses inside
// one prefix earns neither a second rate bucket nor a second slot.
const client = clientNetworkKey(req);
if (!limiter(client))
return send({
...jsonError(429, 'Rate limit exceeded'),
retryAfter: '10',
});
const controller = new AbortController();
const abort = () => controller.abort();
req.once?.('aborted', abort);
res.once?.('close', abort);
try {
const answer = await features(
`${CACHE_VERSION}|features|${upstreamTag()}|${compiled.ql}`,
compiled.ql,
(parsed) => summarizeOsmFeatures(parsed, compiled),
controller.signal,
client,
);
if (!controller.signal.aborted) send(answer);
} catch {
if (!controller.signal.aborted)
send(jsonError(502, 'OpenStreetMap search failed.'));
} finally {
req.removeListener?.('aborted', abort);
res.removeListener?.('close', abort);
}
});
}
+6 -10
View File
@@ -122,14 +122,12 @@ function sanitizeOverpassBody(rawBody) {
const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
// The settings block (`[out:json][timeout:25]`) was blanked together with
// the tag filters, so it arrives here as an empty statement.
if (!stmt || /^out\b/.test(stmt)) continue;
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue;
// Strip output-set assignments (NOT input bounds). With the tag filters
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
// be misread as a spatial bound; bounds live in (...) / function calls /
// set refs, never inside [...], so nothing real was lost.
// Strip output-set assignments (NOT input bounds). Bracket tag filters were
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
// misread as a spatial bound. Bounds live in (...) / function calls / set
// refs, never inside [...], so the probe loses nothing real.
const outSets = [];
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
@@ -168,10 +166,8 @@ function sanitizeOverpassBody(rawBody) {
if (bounded) for (const name of outSets) boundedSets.add(name);
}
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
// rather than passed through as written.
const clamped = data.replace(
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
/\[timeout:\s*(\d+)\s*\]/gi,
(_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
);
@@ -3,24 +3,10 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { spawnSync } from 'node:child_process';
import { overpassProxy } from '../overpass.js';
import { _overpassCache } from './cache.js';
import { sanitizeOverpassBody } from './query.js';
const form = (query) => `data=${encodeURIComponent(query)}`;
// The limiters are module-level, so every test that spends admission mocks a
// clock a full day past the previous one in execution order: no test can see
// another's window, whatever order (or randomization) they run in. A new
// test that reaches admission must call it too: earlier tests leave
// timestamps days ahead of the real clock, and those still count.
let clockBase = Date.now() + 86_400_000;
function freshClock(t) {
clockBase += 86_400_000;
const clock = { now: clockBase };
t.mock.method(Date, 'now', () => clock.now);
return clock;
}
test('bracket validation rejects malformed filters without trusting their contents as bounds', () => {
for (const query of [
'node[',
@@ -51,15 +37,6 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
query.replace('[timeout:999]', '[timeout:30]'),
);
}
// Whitespace inside the directive is clamped too, not passed through.
const spaced = sanitizeOverpassBody(
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
);
assert.equal(spaced.ok, true);
assert.equal(
new URLSearchParams(spaced.body).get('data'),
'[out:json][timeout:30];node(around:20,1,1);out;',
);
});
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
@@ -149,13 +126,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
{ origin: 'null' },
// Forwarding headers do not launder a cross-site request.
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
{
'x-forwarded-proto': 'https',
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) {
const response = await call(route, { headers, forbidRead: true });
assert.equal(response.statusCode, 403);
@@ -165,24 +136,12 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
});
}
test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async (t) => {
freshClock(t);
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => {
const route = handler();
for (const headers of [
{},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
// The README's reverse-proxy deployment: Overpass is keyless, so the
// forwarding headers the proxy adds are admitted (as for terrain heights).
{
host: 'globe.example.lan',
origin: 'https://globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'globe.example.lan',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) {
const response = await call(route, { headers });
assert.equal(response.statusCode, 400);
@@ -199,7 +158,8 @@ test('same-origin, LAN, proxied and non-browser requests still reach validation;
});
test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => {
const clock = freshClock(t);
let now = Date.now() + 120000;
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
@@ -213,216 +173,6 @@ test('invalid requests spend admission quota before parsing, including on an unc
assert.equal(denied.reads, 0);
assert.equal(denied.headers['retry-after'], '60');
assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400);
clock.now += 60_001;
now += 60001;
assert.equal((await call(route, { peer })).statusCode, 400);
});
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
assert.equal(starved.reads, 0);
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const owner = await call(route, { peer });
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
assert.equal(owner.reads, 1);
}
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
});
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
.statusCode,
400,
);
}
const rotated = await call(route, {
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
forbidRead: true,
});
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
assert.equal(
(await call(route, { peer: '2001:db8:1:3::1' })).statusCode,
400,
);
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
400,
);
}
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
clock.now += 60_001;
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
});
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
// Not data: nothing is cached or written to disk by this test.
return new Response('upstream unavailable', { status: 503 });
});
const hit = 'node(around:20,1,1);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
const peer = '10.7.7.7';
// More cache hits than the upstream limiter would allow per peer.
for (let i = 0; i < 100; i++) {
const response = await call(route, { peer, body: form(hit) });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['x-overpass-cache'], 'HIT');
}
assert.equal(fetches, 0);
const miss = await call(route, {
peer,
body: form('node(around:20,2,2);out;'),
});
assert.equal(fetches, 1, 'the first miss still reaches upstream');
assert.notEqual(miss.statusCode, 429);
for (let i = 0; i < 199; i++) {
assert.equal(
(await call(route, { peer, body: form(hit) })).statusCode,
200,
);
}
const exhausted = await call(route, {
peer,
body: form(hit),
forbidRead: true,
});
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
assert.equal(exhausted.reads, 0);
});
test('a local reverse proxy cannot spend the direct-loopback reserve; owner cache hits survive it', async (t) => {
const clock = freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
const hit = 'node(around:20,3,3);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: clock.now,
});
const route = handler();
// The proxy connects over loopback; a rotating forwarded client address
// must not mint fresh buckets, so all 300 land in one socket-keyed bucket.
for (const proxyPeer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const proxied = (i) => ({
peer: proxyPeer,
headers: { 'x-forwarded-for': `203.0.113.${i % 250}` },
body: form(hit),
});
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, proxied(i))).statusCode, 200, proxyPeer);
}
const refused = await call(route, { ...proxied(300), forbidRead: true });
assert.equal(refused.statusCode, 429, `${proxyPeer} proxy bucket is full`);
assert.equal(refused.reads, 0);
for (const peer of ['127.0.0.1', '::1']) {
const owner = await call(route, { peer, body: form(hit) });
assert.equal(owner.statusCode, 200, `direct ${peer} is still admitted`);
assert.equal(owner.headers['x-overpass-cache'], 'HIT');
}
clock.now += 60_001;
assert.equal((await call(route, proxied(0))).statusCode, 200);
}
});
test('proxied loopback traffic draws on the shared remote global window', async (t) => {
const clock = freshClock(t);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { headers: { via: '1.1 proxy' } })).statusCode,
400,
);
}
const starved = await call(route, { peer: '10.0.0.3', forbidRead: true });
assert.equal(starved.statusCode, 429, 'proxied requests counted globally');
assert.equal((await call(route, {})).statusCode, 400, 'owner unaffected');
});
test("a local proxy has its own upstream bucket, so its misses cannot spend the owner's", async (t) => {
freshClock(t);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
});
// Not data: nothing is cached, so every request below is a miss.
t.mock.method(
globalThis,
'fetch',
async () => new Response('upstream unavailable', { status: 503 }),
);
const route = handler();
const miss = form('node(around:20,4,4);out;');
// The upstream limiter's own refusal is the only miss without this header.
const passedLimiter = (response) =>
response.headers['x-overpass-cache'] !== undefined;
const proxied = { peer: '127.0.0.1', headers: { via: '1.1 proxy' } };
for (let i = 0; i < 90; i++) {
const response = await call(route, { ...proxied, body: miss });
assert.equal(passedLimiter(response), true, `proxied miss ${i}`);
}
const spent = await call(route, { ...proxied, body: miss });
assert.equal(spent.statusCode, 429);
assert.equal(passedLimiter(spent), false, 'the proxy bucket is spent');
const owner = await call(route, { peer: '127.0.0.1', body: miss });
assert.equal(passedLimiter(owner), true, 'the owner keeps its own bucket');
});
+3 -13
View File
@@ -124,8 +124,6 @@ async function fetchOverpassPayload(
readBody = readResponseTextCapped,
simplify = simplifyOverpassPayloadBody,
now = Date.now,
timeoutMs = OVERPASS_TIMEOUT_MS,
signal = null,
} = {},
) {
if (!endpoints.length) return overpassNotConfigured();
@@ -137,10 +135,7 @@ async function fetchOverpassPayload(
continue;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const requestSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
try {
const requestUrl = new URL(endpoint);
const authorization =
@@ -161,7 +156,7 @@ async function fetchOverpassPayload(
...(authorization ? { Authorization: authorization } : {}),
},
body,
signal: requestSignal,
signal: controller.signal,
});
const responseBody = await readBody(upstream, maxResponseBytes);
const parsed = parseJson(responseBody);
@@ -212,12 +207,7 @@ async function fetchOverpassPayload(
upstreams: resolveOverpassUpstreamTag(),
rateLimited: false,
};
} catch (error) {
// A route whose last requester disconnected owns this cancellation. It
// is not evidence that the configured upstream is unhealthy, so do not
// put the endpoint on cooldown or translate the abort into a response
// that a now-gone client could cache.
if (signal?.aborted) throw signal.reason ?? error;
} catch {
const failures = (previous?.failures || 0) + 1;
const delay = retryDelay(null, failures - 1, now());
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
+2 -6
View File
@@ -7,13 +7,9 @@ export {
publicRadioHttpsUrl,
} from './radio/stations.js';
export function radioBrowserProxy() {
let middleware;
const initialize = () =>
(middleware ||= createRadioProxyMiddleware({
priorityCountries: process.env.RADIO_PRIORITY_COUNTRIES || '',
}));
const middleware = createRadioProxyMiddleware();
const install = (server) => {
server.middlewares.use('/api/radio', initialize());
server.middlewares.use('/api/radio', middleware);
};
return {
name: 'radio-browser-proxy',
+15 -106
View File
@@ -1,4 +1,3 @@
import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js';
import { lookup as lookupDns } from 'node:dns/promises';
import { randomUUID } from 'node:crypto';
@@ -28,34 +27,6 @@ import {
RADIO_FALLBACK_MIRRORS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './constants.js';
const PRIORITY_COUNTRY_LIMIT = 8;
const COUNTRY_QUERY_LIMIT = 200;
const COUNTRY_RESERVED_STATIONS = 40;
const REFRESH_RETRY_MS = 60_000;
const MAX_MIRROR_ATTEMPTS = 3;
/** Validate an operator-owned list; malformed configuration must not silently widen queries. */
export function parseRadioPriorityCountries(value = '') {
const entries = Array.isArray(value) ? value : String(value).split(',');
const countries = [];
for (const entry of entries) {
const input = String(entry).trim().toUpperCase();
if (!input) continue;
const normalized = normalizeRadioCountryInput(input);
if (!/^[A-Z]{2}$/.test(input) || !normalized.valid)
throw new TypeError(
'RADIO_PRIORITY_COUNTRIES requires ISO alpha-2 country codes',
);
const code = normalized.code;
if (!countries.includes(code)) countries.push(code);
}
if (countries.length > PRIORITY_COUNTRY_LIMIT)
throw new RangeError(
'RADIO_PRIORITY_COUNTRIES supports at most eight countries',
);
return countries;
}
async function mapRadioConcurrent(values, concurrency, mapper) {
const results = new Array(values.length);
let cursor = 0;
@@ -78,9 +49,7 @@ export function createRadioProxyMiddleware({
fetchImpl = null,
lookupImpl = lookupDns,
now = Date.now,
priorityCountries = [],
} = {}) {
const countries = parseRadioPriorityCountries(priorityCountries);
let mirrorCache = { origins: [...RADIO_FALLBACK_MIRRORS], cachedAt: 0 };
let mirrorPromise = null;
let catalogCache = null;
@@ -92,9 +61,6 @@ export function createRadioProxyMiddleware({
const catalogInstance = randomUUID();
let servedStationIds = new Set();
let refreshPromise = null;
let retryAfter = 0;
let partialCatalog = null;
let refreshError = null;
async function fetchJson(url, maxBytes = RADIO_RESPONSE_MAX_BYTES) {
const destination = radioProxyDestination(url);
@@ -173,7 +139,7 @@ export function createRadioProxyMiddleware({
async function fetchPath(pathname) {
let lastError = null;
for (const origin of (await mirrors()).slice(0, MAX_MIRROR_ATTEMPTS)) {
for (const origin of await mirrors()) {
try {
return await fetchJson(`${origin}${pathname}`);
} catch (error) {
@@ -194,27 +160,20 @@ export function createRadioProxyMiddleware({
'aviation',
'marine',
'traffic',
].map((tag) => ({ tag }));
const baseQueryCount = queries.length;
queries.push(...countries.map((country) => ({ country })));
];
const outcomes = await mapRadioConcurrent(
queries,
3,
async ({ tag, country }, index) => {
async (tag, index) => {
const params = new URLSearchParams({
has_geo_info: 'true',
is_https: 'true',
hidebroken: 'true',
order: 'clickcount',
reverse: 'true',
limit: country
? String(COUNTRY_QUERY_LIMIT)
: index === 0
? '1800'
: '220',
limit: index === 0 ? '1800' : '220',
});
if (tag) params.set('tag', tag);
if (country) params.set('countrycode', country);
try {
const rows = await fetchPath(`/json/stations/search?${params}`);
if (!Array.isArray(rows))
@@ -236,12 +195,7 @@ export function createRadioProxyMiddleware({
const rejectedRows = rows.length - structuralRows.length;
const stations = structuralRows
.map(normalizeRadioBrowserStation)
.filter(
(station) =>
station && (!country || station.countryCode === country),
);
if (country && rows.length > COUNTRY_QUERY_LIMIT)
throw new Error('Radio Browser exceeded the country query limit');
.filter(Boolean);
const requestedTag = normalizeRadioTag(tag);
const requestedTagCovered =
!requestedTag ||
@@ -253,12 +207,10 @@ export function createRadioProxyMiddleware({
),
);
return {
// A valid country response can contain no playable stations. Country
// coverage is advisory; only broad/specialist queries gate health.
// Specialist responses need an accepted station with matching tags.
succeeded: country
? rows.length === 0 || structuralRows.length > 0
: stations.length > 0 && requestedTagCovered,
// Query coverage is based on accepted rows, not merely a payload that
// happens to match the upstream schema. Specialist responses must
// also contain an accepted station tagged for the requested category.
succeeded: stations.length > 0 && requestedTagCovered,
stations,
rejectedRows,
};
@@ -281,24 +233,9 @@ export function createRadioProxyMiddleware({
seen.add(station.id);
selected.push(station);
};
// Reserve country slots first; low-click regional stations cannot lose all
// representation to the global charts. Keep room for all eight specialist
// seeds (8 * 45) and global fill even at the eight-country limit (8 * 40).
for (const rows of resultSets.slice(baseQueryCount)) {
const ranked = [...rows].sort(
(a, b) => b.clickCount - a.clickCount || a.id.localeCompare(b.id),
);
let added = 0;
for (const station of ranked) {
if (seen.has(station.id)) continue;
take(station);
if (++added >= COUNTRY_RESERVED_STATIONS) break;
}
}
// Seed specialist station-tag queries before popularity fill so operational
// categories remain represented even when global click charts skew musical.
for (const rows of resultSets.slice(1, baseQueryCount))
rows.slice(0, 45).forEach(take);
for (const rows of resultSets.slice(1)) rows.slice(0, 45).forEach(take);
resultSets
.flat()
.sort(
@@ -306,9 +243,9 @@ export function createRadioProxyMiddleware({
)
.forEach(take);
const timestamp = now();
const successfulQueries = outcomes
.slice(0, baseQueryCount)
.filter((outcome) => outcome.succeeded).length;
const successfulQueries = outcomes.filter(
(outcome) => outcome.succeeded,
).length;
const broadQueryHealthy =
outcomes[0].succeeded && outcomes[0].stations.length > 0;
const healthReasons = [];
@@ -324,18 +261,7 @@ export function createRadioProxyMiddleware({
);
const coverage = {
successfulQueries,
totalQueries: baseQueryCount,
...(countries.length
? {
priorityCountries: countries.map((country, index) => ({
countryCode: country,
succeeded: outcomes[baseQueryCount + index].succeeded,
stationCount: selected.filter(
(station) => station.countryCode === country,
).length,
})),
}
: {}),
totalQueries: queries.length,
stationCount: selected.length,
healthyStationMinimum: RADIO_CATALOG_HEALTHY_MIN_STATIONS,
};
@@ -389,24 +315,7 @@ export function createRadioProxyMiddleware({
return { ...catalogCache, stale: false };
}
if (!refreshPromise) {
refreshPromise = (async () => {
if (now() < retryAfter) {
if (refreshError) throw refreshError;
return partialCatalog;
}
try {
const catalog = await refreshCatalog();
refreshError = null;
partialCatalog = catalog.degraded ? catalog : null;
retryAfter = catalog.degraded ? now() + REFRESH_RETRY_MS : 0;
return catalog;
} catch (error) {
refreshError = error;
partialCatalog = null;
retryAfter = now() + REFRESH_RETRY_MS;
throw error;
}
})().finally(() => {
refreshPromise = refreshCatalog().finally(() => {
refreshPromise = null;
});
}
@@ -204,97 +204,6 @@ const ADMIN_EVIDENCE = Object.freeze({
},
});
/**
* Nominatim address types whose meaning is stable enough for the area consumer's
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
* vary by country, so they remain unverified instead of being guessed.
*/
const VERIFIED_ADMIN_LEVEL = Object.freeze({
country: 'country',
state: 'admin1',
province: 'admin1',
county: 'admin2',
state_district: 'admin2',
});
const EXPLICIT_NAME_LEVEL = Object.freeze({
country: 'country',
nation: 'country',
province: 'admin1',
state: 'admin1',
region: 'admin1',
oblast: 'admin1',
prefecture: 'admin1',
canton: 'admin1',
governorate: 'admin1',
county: 'admin2',
district: 'admin2',
department: 'admin2',
zone: 'retired',
});
function explicitNameEvidence(value) {
const normalized = String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
const prefix = normalized.match(
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
)?.[1];
const suffix = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
)?.[1];
const levels = new Set(
[prefix, suffix]
.filter(Boolean)
.map((type) => EXPLICIT_NAME_LEVEL[type])
.filter(Boolean),
);
if (levels.size > 1) return { contradictory: true, level: null };
return { contradictory: false, level: levels.values().next().value || null };
}
/**
* Verified administrative identity carried by the selected result itself.
* Never infer it from the request, desired kind, rank, or a separate geocoder
* anchor. A retired name is preserved without a level so downstream policy can
* reject it explicitly; every other contradiction stays wholly unverified.
*/
function selectedAdminIdentity(kind, row) {
if (kind !== 'admin') return null;
const adminLevel =
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
const authoritativeAdminArea = String(row?.name || '').trim();
if (!adminLevel || !authoritativeAdminArea) return null;
// Validate the complete authoritative name. `adminArea` is truncated only
// for display after every prefix/suffix signal has been considered.
const { contradictory, level: nameLevel } = explicitNameEvidence(
authoritativeAdminArea,
);
if (contradictory) return null;
const adminArea = authoritativeAdminArea.slice(0, 160);
if (nameLevel === 'retired') {
// Nepal replaced its zones in 2015, but zones are current units elsewhere
// (Ethiopia). Without a country the name stays retired.
if (!zoneIsCurrentIn(row)) return { adminArea };
return { adminArea, adminLevel };
}
if (nameLevel && nameLevel !== adminLevel) return null;
return { adminArea, adminLevel };
}
/** Whether the row names a country (last display_name part) other than Nepal. */
function zoneIsCurrentIn(row) {
const country = String(row?.display_name || '')
.split(',')
.slice(1)
.pop()
?.trim()
.toLocaleLowerCase();
return Boolean(country) && country !== 'nepal';
}
/** Whether supplied administrative evidence contradicts the accepted identity. */
function adminLevelFits(kind, row) {
const evidence = ADMIN_EVIDENCE[kind];
@@ -675,7 +584,6 @@ export function selectOutlineResult(rows, { kind, query }) {
row.osm_type && row.osm_id != null
? `${row.osm_type}/${row.osm_id}`
: null,
...(selectedAdminIdentity(kind, row) || {}),
polygons,
},
});
Loaded 100 of 527 files, more files were not shown because too many files have changed in this diff. Show more