mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9542a5e58b | ||
|
|
f86d1c3296 | ||
|
|
a08a53cf94 | ||
|
|
9ba1b345e6 | ||
|
|
b74a0233d9 | ||
|
|
ee7a5fb30b | ||
|
|
491a856a6b | ||
|
|
19d8ed5867 | ||
|
|
10d10eab6b | ||
|
|
d43478a547 | ||
|
|
e1cc7afacf | ||
|
|
a2f61d4def | ||
|
|
e1bd2f7bf1 | ||
|
|
b0483dc546 | ||
|
|
60644c5f5d | ||
|
|
5bf3beea84 | ||
|
|
e8e49a49e0 | ||
|
|
88b8406073 | ||
|
|
a2099358fd | ||
|
|
ee05e922c0 | ||
|
|
eaf81024d7 | ||
|
|
f752b5eea1 | ||
|
|
aa16b7c3b0 | ||
|
|
80fc8f0e1c | ||
|
|
05df7083b9 | ||
|
|
eaa51a24fe | ||
|
|
b33cae4b74 | ||
|
|
2a612b7315 | ||
|
|
9c87fabf88 | ||
|
|
992412fc9a | ||
|
|
0646630dec | ||
|
|
913d45bc07 | ||
|
|
9b4ccbff62 | ||
|
|
e6f1dd70a1 | ||
|
|
81513f9aae | ||
|
|
9954e9f991 | ||
|
|
6cdd6c8af9 | ||
|
|
bbf4976f5e | ||
|
|
98b8c6c581 | ||
|
|
bb74071dc2 | ||
|
|
7aa9bbd3be | ||
|
|
42faed3aea | ||
|
|
857767842f | ||
|
|
fec93aa817 | ||
|
|
f7d3db5875 | ||
|
|
3318390e3f | ||
|
|
1dbc8a4452 | ||
|
|
e03bf008f3 |
No files matched your search
+38
-22
@@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY=
|
||||
# single shared key keeps working — splitting is opt-in.
|
||||
# SERVER-SIDE ONLY.
|
||||
GOOGLE_MAPS_SERVER_API_KEY=
|
||||
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
|
||||
# (/api/google/nearby-places), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
|
||||
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
|
||||
# (plus per-API quotas under APIs & Services -> Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
|
||||
# Per-IP rate limit for the Google Places cost endpoints
|
||||
# (/api/google/nearby-places + /api/google/text-search), in requests per minute.
|
||||
# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it
|
||||
# with a positive integer; 60 is a sensible tightening whenever HOST is not
|
||||
# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a
|
||||
# typo, a stray unit) falls back to the default rather than to unlimited, so a
|
||||
# slip cannot quietly disarm the guard. A positive value below 1 counts as 1.
|
||||
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
|
||||
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
|
||||
# spend protection set provider-side budget alerts: Google Cloud Console ->
|
||||
# Billing -> Budgets & alerts (plus per-API quotas under APIs & Services ->
|
||||
# Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60 # uncomment to tighten from the default 120
|
||||
|
||||
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
|
||||
# imagery, and Cesium World Terrain. The free Community plan is for eligible
|
||||
@@ -65,15 +68,19 @@ OPENAI_REALTIME_REASONING_EFFORT=low
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS=3000
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
|
||||
# Per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP spend when self-hosting on a network.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
|
||||
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||
# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary
|
||||
# use several times over: an open tab asks for a HUD summary every 15s (4/min)
|
||||
# and a voice session mints one token. Raise or lower it with a positive
|
||||
# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo,
|
||||
# a stray unit) falls back to the default rather than to unlimited, so a slip
|
||||
# cannot quietly disarm the guard. A positive value below 1 counts as 1.
|
||||
# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart;
|
||||
# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard
|
||||
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
||||
# Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
||||
|
||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||
# Default: oauth — requires client credentials below
|
||||
@@ -101,6 +108,11 @@ PORT=4173
|
||||
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
|
||||
# HOST=localhost
|
||||
# HOST=0.0.0.0
|
||||
# Optional: comma-separated LAN hostnames to accept, in addition to localhost
|
||||
# and IP addresses, for example GEV_ALLOWED_HOSTS=globe.lan,globe.internal.
|
||||
# Binding HOST=0.0.0.0 alone does NOT trust every Host header; suffix and
|
||||
# wildcard entries are ignored.
|
||||
# GEV_ALLOWED_HOSTS=
|
||||
|
||||
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
|
||||
# browser — the client polls the /api/firms proxy). Free key:
|
||||
@@ -110,7 +122,7 @@ PORT=4173
|
||||
|
||||
# Optional: Live AIS vessels layer
|
||||
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
|
||||
VITE_AIS_LIVE_API_URL=/api/ais-live
|
||||
VITE_AIS_LIVE_API_URL=/api/vessels
|
||||
# Client render cap. The server can keep a larger AISStream cache, but drawing
|
||||
# every global vessel at once can overwhelm WebGL on laptops.
|
||||
VITE_AIS_LIVE_MAX_ROWS=12000
|
||||
@@ -138,7 +150,7 @@ AISSTREAM_API_KEY=
|
||||
|
||||
# Optional: TomTom live traffic flow (BYOK freemium — the free tier is 200,000
|
||||
# Traffic Flow & Incidents tile requests per MONTH, not per day; no credit card
|
||||
# required. Get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||
# required. Get a key at https://my.tomtom.com/keys). SERVER-SIDE ONLY:
|
||||
# the browser fetches same-origin /api/tomtom/* and never sees the key.
|
||||
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
|
||||
# hardcoded per-road-class speeds) — no key required for the layer to work.
|
||||
@@ -180,6 +192,10 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
|
||||
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
|
||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
||||
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
||||
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
||||
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
@@ -188,6 +204,6 @@ AISSTREAM_API_KEY=
|
||||
OVERPASS_UPSTREAMS=
|
||||
|
||||
# Optional: who may frame the app in embed mode (?embed=1), as CSP
|
||||
# frame-ancestors sources such as "https://example.com". Unset allows any
|
||||
# page; every other page of the app can never be framed.
|
||||
# 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=
|
||||
+92
-6
@@ -2,6 +2,91 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||
loads (~850; faulty ones are dropped) under one "Norway" category, and the
|
||||
~135 working cameras that publish HLS play as live video through the existing in-memory
|
||||
relay with the still as fallback. Frames and manifests are pinned to each
|
||||
camera's own path on the agency hosts. `CCTV_VEGVESEN_ENABLED=0` disables
|
||||
the pack, `CCTV_VEGVESEN_MAX_SOURCES` caps it (keeping cameras nearest the
|
||||
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
||||
under NLOD 2.0.
|
||||
|
||||
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
||||
|
||||
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
||||
county, military-name and neighborhood packs were emitted twice by the
|
||||
production build, as the JSON the browser fetches and as an unused
|
||||
JavaScript copy; `dist/` drops from 54 MB to 42 MB. Under Node the loader
|
||||
reads the JSON file directly (reporting that Node 24.14 or newer is needed
|
||||
on a runtime too old to do so), and a test keeps app code from importing a
|
||||
pack as a module.
|
||||
|
||||
- Apply the Host check before the app's own routes. Vite installs its Host
|
||||
check after the middleware that plugins add, so the provider and `/api`
|
||||
routes used to answer any Host, including a DNS-rebinding name, in every
|
||||
mode. A first-running middleware now applies the same allowed hosts on the
|
||||
dev and preview servers.
|
||||
|
||||
- Refuse cross-site browser requests to the cost-bearing and log endpoints
|
||||
(`/api/realtime/token`, `/api/openai/hud-summary`,
|
||||
`/api/google/nearby-places`, `/api/google/text-search`,
|
||||
`/api/realtime/debug-log`): a foreign or opaque
|
||||
Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403,
|
||||
while loopback tools and LAN use keep working. The dev and preview servers
|
||||
send one shared Content-Security-Policy (James Sumpter, #242).
|
||||
|
||||
- The voice debug log records the server's own time: a record can no longer
|
||||
supply its `loggedAt`. The dev and preview servers also send
|
||||
`X-Content-Type-Options: nosniff` (findings by Sunil, #710).
|
||||
|
||||
- Keep the Host-header check when binding to all interfaces. `HOST=0.0.0.0`
|
||||
used to accept any Host; the dev and preview servers now accept IP
|
||||
addresses, `localhost` and the LAN hostnames listed in `GEV_ALLOWED_HOSTS`
|
||||
(suffix and wildcard entries are ignored), which also keeps DNS-rebinding
|
||||
names out in LAN mode. The `.local` suffix is no longer accepted by default;
|
||||
list such a name explicitly (Puspo Aditya, #97, fixes #21).
|
||||
|
||||
- Throttle the cost-bearing proxies by default instead of on request. The
|
||||
OpenAI endpoints (`/api/realtime/token`, `/api/openai/hud-summary`) now allow
|
||||
30 requests per minute per client IP and the Google Places endpoints
|
||||
(`/api/google/nearby-places`, `/api/google/text-search`) 120 — the caps the
|
||||
Pinokio build already ships, so the packaged app is unaffected and only an
|
||||
unconfigured server changes, from unlimited to what the product already runs
|
||||
with. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` still
|
||||
override the caps, and exactly `0` disables them; a value that cannot be read
|
||||
as a number now falls back to the default rather than to unlimited, so a typo
|
||||
cannot silently disarm the guard (daikaginza, #683).
|
||||
|
||||
- Tighten the local MCP server and embed mode. The panel's `panel_request`
|
||||
needs the key in its MCP server's panel page, and refuses Provider
|
||||
Settings, credential and model endpoints, `/mcp` and development server
|
||||
routes in any letter case, encoding or dot suffix; panels on one server share six
|
||||
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
|
||||
requires a Host on the port it reached and an Origin from that host, and
|
||||
times out request bodies after 30 seconds; stdio honors
|
||||
`notifications/cancelled`. Embed mode is no longer framable unless
|
||||
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page, and view answers go
|
||||
only to the origin that sent the view. SECURITY.md describes the MCP surface
|
||||
and the panel's browser keys.
|
||||
|
||||
- Cockpit now enters on the existing matching map style while keeping one fixed,
|
||||
duplicate-free carousel over `Normal / CRT / NVG / FLIR / Anime / Noir / Snow`.
|
||||
Normal remains a real unfiltered option; Cockpit-only choices still restore
|
||||
the captured map style through both Exit Cockpit and Reset.
|
||||
- Cyber's compact right-rail and Cockpit utility buttons now center their glyphs
|
||||
vertically and share the same inset and edge alignment.
|
||||
- Keep Cyber Voice help/error popups and Location/Visual Presets pins clear of
|
||||
their decorative frames. Leave space above attribution for its full logo row.
|
||||
- Add an intentionally future-facing, opt-in panel surface contract for new
|
||||
panels to inherit compatible Normal, Cyber and Cockpit styling. Its first
|
||||
production adopter will land separately after this change.
|
||||
- Restore the user's previous visual preset when they explicitly switch from
|
||||
Cyber to another HUD layout, without overriding scene or shared-link state.
|
||||
- Move the aligned Cyber side-panel rails upward on desktop so the left stack
|
||||
clears the lower coordinate card; keep Cockpit's independent visor layout.
|
||||
|
||||
## [0.2.0] — 2026-10-02 — God's Eye View through Your Agent
|
||||
|
||||
- Show God's Eye View inside AI conversations. Answers that can be shown
|
||||
@@ -1194,6 +1279,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0.
|
||||
Browser QA awaits the new asynchronous executable-path lookup.
|
||||
|
||||
### Live CCTV video
|
||||
|
||||
- Live HLS video shares one decoder between the camera panel and projection,
|
||||
with a DelDOT HTTPS source pack. Credit: Daniel Slay (@Danielslay86), PR #489.
|
||||
- Maintainer adjustments bound sessions and downloads, remove disk/subprocess
|
||||
remuxing, reject redirects, and clean up playback on switching or disabling.
|
||||
|
||||
## [0.1.1] — 2026-09-01 — Installation and live-data fixes
|
||||
|
||||
### Changed
|
||||
@@ -1431,9 +1523,3 @@ represent previously published GitHub Releases.
|
||||
|
||||
- Initial project version.
|
||||
|
||||
### Live CCTV integration candidate
|
||||
|
||||
- Live HLS video shares one decoder between the camera panel and projection,
|
||||
with a DelDOT HTTPS source pack. Credit: Daniel Slay (@Danielslay86), PR #489.
|
||||
- Maintainer adjustments bound sessions and downloads, remove disk/subprocess
|
||||
remuxing, reject redirects, and clean up playback on switching or disabling.
|
||||
+3
-1
@@ -36,7 +36,7 @@ How to read this:
|
||||
| **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)" |
|
||||
| **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://developer.tomtom.com) (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 |
|
||||
| **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 |
|
||||
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" |
|
||||
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
|
||||
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
|
||||
@@ -54,6 +54,7 @@ How to read this:
|
||||
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
|
||||
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
|
||||
@@ -88,6 +89,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
|
||||
- **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 seven 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.
|
||||
- **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 malformed or empty successful payloads are rejected atomically. 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.
|
||||
|
||||
@@ -294,7 +294,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **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** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). 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) | 🟢 |
|
||||
| 📻 **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 | Operator GTFS-Realtime feeds | 🟢 |
|
||||
@@ -421,7 +421,7 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available |
|
||||
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
|
||||
|
||||

|
||||
|
||||
@@ -512,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas
|
||||
|
||||
### 🔒 Sharing an instance
|
||||
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux). IP addresses work as they are; if you use a LAN hostname, add it with `GEV_ALLOWED_HOSTS=globe.lan`, since binding to `0.0.0.0` does not trust arbitrary Host headers. But know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Per-IP throttles on the cost-bearing endpoints are on by default (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example` to tune them), but before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
|
||||
Provider Settings is disabled when the server is shared, so remote users cannot
|
||||
access the key-entry panel.
|
||||
|
||||
+15
-4
@@ -18,8 +18,8 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
|
||||
| Key | Where it lives | How the browser uses it |
|
||||
|-----|----------------|--------------------------|
|
||||
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
|
||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
|
||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
|
||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
|
||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
||||
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
||||
|
||||
### Two deliberately client-side keys — restrict them
|
||||
@@ -71,14 +71,16 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`) 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.
|
||||
- **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.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
|
||||
|
||||
- **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).
|
||||
- **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 (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and 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**.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||
@@ -90,6 +92,15 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
||||
loopback only. Use a separately reviewed authentication proxy for remote
|
||||
access and keep provider-side quotas as the spend backstop.
|
||||
|
||||
## MCP server and panel
|
||||
|
||||
God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`) and at `/mcp` on the development and preview servers.
|
||||
|
||||
- **`/mcp` answers only direct local requests**: a loopback connection naming a loopback host on the port it reached, a browser `Origin` (when sent) from that same host, no proxy forwarding headers, and launcher sharing off. It accepts only JSON, so a web page cannot post to it cross-site. This is transport safety, not authentication: any program on your machine can use the tools, and some of them spend the same provider quotas as the app.
|
||||
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
|
||||
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
|
||||
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Host header allowlist for the Vite dev/preview server. The configuration
|
||||
* factory passes the configured names in; this module reads no environment.
|
||||
*
|
||||
* Binding to a wildcard interface only controls which network interfaces accept
|
||||
* connections. It must not turn into a wildcard Host-header policy: this server
|
||||
* brokers configured API keys through local proxy routes.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export const DEFAULT_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1']);
|
||||
|
||||
/**
|
||||
* Resolve Vite's allowedHosts setting from an optional comma-separated list.
|
||||
* Empty entries and suffix/wildcard entries are ignored; repeated names keep
|
||||
* their first occurrence. Vite treats leading-dot values as suffix wildcards,
|
||||
* which would defeat an explicit host policy.
|
||||
*
|
||||
* @param {string|undefined|null} configuredHosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function resolveAllowedHosts(configuredHosts) {
|
||||
const hosts = String(configuredHosts ?? '')
|
||||
.split(',')
|
||||
.map((host) => host.trim())
|
||||
.filter((host) => host && !host.startsWith('.') && !host.includes('*'));
|
||||
return [...new Set([...DEFAULT_ALLOWED_HOSTS, ...hosts])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Host header names a host the server answers, by Vite's own rule:
|
||||
* IP addresses, `localhost` and `*.localhost` always; then `additional` names
|
||||
* (the configured server host) and `allowedHosts` (a leading dot allows a
|
||||
* domain and its subdomains). `allowedHosts === true` allows every host.
|
||||
*
|
||||
* @param {string|undefined} hostHeader
|
||||
* @param {string[]|true} allowedHosts
|
||||
* @param {string[]} [additional]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowedHost(hostHeader, allowedHosts, additional = []) {
|
||||
if (allowedHosts === true) return true;
|
||||
const host = String(hostHeader ?? '').trim();
|
||||
if (!host) return false;
|
||||
if (host[0] === '[') {
|
||||
const end = host.indexOf(']');
|
||||
return end > 0 && isIP(host.slice(1, end)) === 6;
|
||||
}
|
||||
const colon = host.indexOf(':');
|
||||
const hostname = (colon === -1 ? host : host.slice(0, colon)).toLowerCase();
|
||||
if (isIP(hostname) === 4) return true;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (additional.includes(hostname)) return true;
|
||||
return (allowedHosts || []).some(
|
||||
(allowed) =>
|
||||
allowed === hostname ||
|
||||
(allowed[0] === '.' &&
|
||||
(allowed.slice(1) === hostname || hostname.endsWith(allowed))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that applies the Host check before every other middleware.
|
||||
* Vite installs its own check after the middleware plugins add in
|
||||
* `configureServer`, so without this the app's `/api` routes would answer any
|
||||
* Host, including a DNS-rebinding name. It runs first (`enforce: 'pre'`) and
|
||||
* uses the same allowed hosts as Vite, on the dev and preview servers.
|
||||
*/
|
||||
export function hostCheckPlugin() {
|
||||
const middleware = (config, preview) => {
|
||||
const allowed = preview
|
||||
? config.preview.allowedHosts
|
||||
: config.server.allowedHosts;
|
||||
const additional = [config.server.host, config.preview.host].filter(
|
||||
(value) => typeof value === 'string' && value,
|
||||
);
|
||||
return (req, res, next) => {
|
||||
if (isAllowedHost(req.headers.host, allowed, additional)) return next();
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Blocked request. This host is not allowed.');
|
||||
};
|
||||
};
|
||||
return {
|
||||
name: 'host-check',
|
||||
enforce: 'pre',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware(server.config, false));
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware(server.config, true));
|
||||
},
|
||||
};
|
||||
}
|
||||
+29
-11
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* Lets other pages frame the app in embed mode (`?embed=1`). Every other
|
||||
* Lets chosen pages frame the app in embed mode (`?embed=1`). Every
|
||||
* document keeps the server's `X-Frame-Options: DENY` and
|
||||
* `frame-ancestors 'none'`, which protect Provider Settings. Embed mode
|
||||
* shows only the globe, so for those documents framing is limited to
|
||||
* `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default.
|
||||
* "Any" sends no framing restriction at all: `frame-ancestors *` would still
|
||||
* refuse sandboxed frames with an opaque origin, which panel hosts use.
|
||||
* `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names who may
|
||||
* frame embed-mode documents: CSP frame-ancestors sources, or `*` for any
|
||||
* page. A framing page can change what the app shows, so no page may frame
|
||||
* it by default. Other documents, including Provider Settings, are never
|
||||
* framable. "Any" sends no framing restriction at all: `frame-ancestors *`
|
||||
* would still refuse sandboxed frames with an opaque origin.
|
||||
*/
|
||||
|
||||
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
|
||||
|
||||
/** The directives of a CSP header value, minus any frame-ancestors rule. */
|
||||
function withoutFraming(value) {
|
||||
return String(value ?? '')
|
||||
.split(';')
|
||||
.map((directive) => directive.trim())
|
||||
.filter(Boolean)
|
||||
.filter((directive) => !/^frame-ancestors\b/i.test(directive));
|
||||
}
|
||||
|
||||
/** Whether a request is for an embed-mode document. */
|
||||
export function isEmbedDocumentRequest(url) {
|
||||
let parsed;
|
||||
@@ -26,10 +36,13 @@ export function isEmbedDocumentRequest(url) {
|
||||
|
||||
/** Vite plugin applying the embed framing policy on dev and preview servers. */
|
||||
export function embedFramingPlugin({
|
||||
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*',
|
||||
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '',
|
||||
} = {}) {
|
||||
const anywhere = ancestors.trim() === '*';
|
||||
const policy = `frame-ancestors ${ancestors}`;
|
||||
const allowed = ancestors.trim();
|
||||
// Unset: embed documents keep the server's framing protection.
|
||||
if (!allowed) return { name: 'embed-framing' };
|
||||
const anywhere = allowed === '*';
|
||||
const policy = `frame-ancestors ${allowed}`;
|
||||
const install = (server) => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!isEmbedDocumentRequest(req.url)) return next();
|
||||
@@ -39,8 +52,13 @@ export function embedFramingPlugin({
|
||||
res.setHeader = (name, value) => {
|
||||
const key = String(name).toLowerCase();
|
||||
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
|
||||
if (key === 'content-security-policy' && !anywhere)
|
||||
return setHeader(name, policy);
|
||||
if (key === 'content-security-policy') {
|
||||
// Keep every other directive of the server's policy (script-src,
|
||||
// connect-src, ...) and only swap the framing rule.
|
||||
const kept = withoutFraming(value);
|
||||
const next = anywhere ? kept : [...kept, policy];
|
||||
return next.length ? setHeader(name, next.join('; ')) : res;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
if (!anywhere) setHeader('Content-Security-Policy', policy);
|
||||
|
||||
+58
-10
@@ -1,8 +1,51 @@
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* No inline script is permitted, and the only foreign script origins are the
|
||||
* three that the Bhote Koshi event's embedded media needs (YouTube, Facebook
|
||||
* and X), which frame-src matches for their players and posts. 'unsafe-eval' is
|
||||
* required: Knockout (bundled inside @cesium/widgets) resolves the global
|
||||
* object with `(0, eval)("this")` at module load, and without it the Cesium
|
||||
* widget never initializes (verified in headless Chrome). It also covers
|
||||
* Cesium's WASM decoders. `blob:` is required in the built app: Cesium's
|
||||
* bundled workers bootstrap through `importScripts(blob:...)`, which a worker
|
||||
* checks against script-src (the dev server loads them by URL instead).
|
||||
* Widen any other directive only for a real violation.
|
||||
*/
|
||||
export const BROWSER_CSP = [
|
||||
"default-src 'self'",
|
||||
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
|
||||
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' blob: data: https: wss: ws:",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
// The same media's players and posts, which load in frames.
|
||||
"frame-src 'self' blob: https://www.youtube-nocookie.com https://www.youtube.com https://www.facebook.com https://platform.twitter.com",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ');
|
||||
|
||||
/** Response headers shared by the dev and preview servers. */
|
||||
export const BROWSER_HEADERS = Object.freeze({
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': BROWSER_CSP,
|
||||
// Responses are only ever used as the type the server declares.
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
/** Build browser assets with explicit inputs; never load environment or providers. */
|
||||
export function createBrowserViteConfig({
|
||||
plugins = [],
|
||||
@@ -11,10 +54,13 @@ export function createBrowserViteConfig({
|
||||
cesiumToken,
|
||||
host = 'localhost',
|
||||
port = 4173,
|
||||
allowedHosts = DEFAULT_ALLOWED_HOSTS,
|
||||
command,
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
...plugins,
|
||||
@@ -39,20 +85,22 @@ export function createBrowserViteConfig({
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
port: parseInt(port, 10) || 4173,
|
||||
allowedHosts:
|
||||
host === '0.0.0.0' || host === '::'
|
||||
? true
|
||||
: ['localhost', '127.0.0.1', '.local'],
|
||||
// A wildcard bind only chooses interfaces; it never disables Vite's
|
||||
// Host-header check. LAN hostnames are added explicitly (see
|
||||
// build/allowedHosts.js); IP addresses are always accepted.
|
||||
allowedHosts: [...allowedHosts],
|
||||
fs: {
|
||||
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
||||
},
|
||||
// These headers protect the document containing Provider Settings.
|
||||
// Embed-mode documents are framable instead; see embed-framing.js.
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||
},
|
||||
// These headers protect the document containing Provider Settings and
|
||||
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
|
||||
// Embed-mode documents are framable instead: embed-framing.js rewrites
|
||||
// only the frame-ancestors directive and keeps the rest of the policy.
|
||||
headers: BROWSER_HEADERS,
|
||||
},
|
||||
// The preview server serves the same documents, so it carries the same
|
||||
// framing + CSP hardening (one constant, no drift).
|
||||
preview: { headers: BROWSER_HEADERS },
|
||||
define: {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||
|
||||
+55
-23
@@ -10,7 +10,8 @@ The panel runs the app's panel build (`npm run build:panel`, served at
|
||||
`/panel/`) and loads it, with its data, through the MCP server, so it works
|
||||
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
|
||||
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
|
||||
new views from the page that frames it. MCP leads with the tools that find
|
||||
new views from the page that frames it; framing is off unless
|
||||
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find
|
||||
what to show. See [tools and the MCP server](TOOLS.md).
|
||||
|
||||
## Tools and local MCP server — October 1, 2026
|
||||
@@ -20,7 +21,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||
reading from a running app's `/api` routes (default `http://localhost:4173`).
|
||||
The development and preview servers also serve the tools over HTTP at `/mcp`,
|
||||
accepting only local requests with loopback hosts and origins.
|
||||
accepting only direct local requests: a loopback host on the port reached,
|
||||
an `Origin` (when sent) from that same host, no proxy forwarding headers, and
|
||||
launcher sharing off.
|
||||
Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
||||
area, by identifier, tracks, and type and route lookups), ships (in an area,
|
||||
by identifier, and tracks), satellites (next
|
||||
@@ -37,11 +40,12 @@ lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
|
||||
## Cyber HUD — September 23, 2026
|
||||
## Cyber HUD — September 30, 2026
|
||||
|
||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||
action and shared visual state. An explicit first transition into Cyber selects
|
||||
FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain
|
||||
FLIR with Ironbow 0.42. An explicit switch back to another HUD layout restores
|
||||
the visual preset used before Cyber; restored links and scenes remain
|
||||
authoritative. The skin uses shared red/slate panel treatments in map and cockpit,
|
||||
with compact 200px collapsed controls and wider expanded panels. Other HUD
|
||||
layouts retain their existing presentation.
|
||||
@@ -54,6 +58,21 @@ headers and decorative frames. The narrow-screen rail remains scrollable to reac
|
||||
each panel. Radio retains the shared nested Context player and compact
|
||||
header disclosure, without relocating playback controls on theme changes.
|
||||
|
||||
Voice help/error popups and Location/Visual Presets pins extend beyond their
|
||||
Cyber frames without being clipped: the shapes decorate non-interactive
|
||||
pseudo-elements. The lower-left telemetry card leaves room for attribution's
|
||||
full logo row. New panels can use the [shared surface contract](panel-surfaces.md)
|
||||
for theme tokens, rail input and a fixed header with a bounded scroll body.
|
||||
The contract is intentionally future-facing and opt-in; its first production
|
||||
adopter will land after this change. Existing owners still control disclosure,
|
||||
persistence and placement, while small-screen rail popup clipping and other
|
||||
integration limits remain documented in the contract.
|
||||
|
||||
On desktop, Cyber raises the left rail so it clears the lower coordinate card.
|
||||
`layoutRightPanelRail` reads that rendered top as its measured `baseTop`, which
|
||||
keeps the right rail aligned. Cockpit retains its separate rail placement for
|
||||
its visor layout.
|
||||
|
||||
Sonar has one contact-highlighting method. Native Cesium points, billboards and
|
||||
labels are treated in GPU draw commands without replacing their positions,
|
||||
identities or pick commands. Model and canvas/DOM overlays retain their own
|
||||
@@ -912,10 +931,21 @@ errors.
|
||||
## Places and CCTV request bounds
|
||||
|
||||
With a Google key configured, nearby and text search reject missing, blank,
|
||||
non-numeric and out-of-range coordinates before the opt-in limiter and upstream
|
||||
non-numeric and out-of-range coordinates before the per-IP limiter and upstream
|
||||
request. Text search also requires a nonblank query. Keyless requests retain
|
||||
their `configured: false` response.
|
||||
|
||||
The cost-bearing proxies are throttled per client IP without configuration:
|
||||
`/api/realtime/token` and `/api/openai/hud-summary` share 30 requests per
|
||||
minute per IP, `/api/google/nearby-places` and `/api/google/text-search` share
|
||||
120 — the caps the Pinokio build already sets, so the packaged app is
|
||||
unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` override
|
||||
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.
|
||||
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.
|
||||
|
||||
CCTV media waits at most 15 seconds for upstream response headers and returns
|
||||
504 on timeout. Its timer stops when headers arrive, so live bodies can continue
|
||||
streaming; a separate 30-second idle deadline then bounds the gap between
|
||||
@@ -1431,7 +1461,7 @@ This is the current runtime/source-of-truth snapshot for the project.
|
||||
> dependency list rather than a fix for one layer, and the dependencies reach
|
||||
> it by different routes:
|
||||
> - **AIS vessels is its reachable producer.** `enable()`/`update()` both
|
||||
> resolve as soon as the first `/api/ais-live` poll answers, so the manager's
|
||||
> resolve as soon as the first `/api/vessels` poll answers, so the manager's
|
||||
> lifecycle settles to `enabled` — but until the server-side socket delivers
|
||||
> a position, `firstConnectPhase` stays `'loading'` and `getStats()` reports
|
||||
> `loading: true`, `lastUpdate: null`, count 0, and an UNDEFINED status.
|
||||
@@ -1835,7 +1865,7 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
||||
> the share payload; Radio restores only its allowlisted filter and volume.
|
||||
> - **AIS feed watchdog (2026-08-18):** feed liveness is judged by DATA, not
|
||||
> socket state — AISStream can complete the handshake and then deliver
|
||||
> nothing forever. `/api/ais-live` reports `live | stale | reconnecting |
|
||||
> nothing forever. `/api/vessels` reports `live | stale | reconnecting |
|
||||
down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> `silentForMs`, `reconnectAttempt` and `nextAttemptAt`. Silence is REPORTED
|
||||
> at 120s and ACTED ON at 300s; recovery walks a 5s/15s/60s/300s ladder and
|
||||
@@ -1980,11 +2010,12 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> registered for lifecycle and restoration but is not duplicated in Data Layers.
|
||||
> Inside Cockpit, the focused summary card is titled Contact in both visible
|
||||
> copy and its accessible control labels.
|
||||
> The top-center Cockpit vision cycle shows the inherited map preset name
|
||||
> (for example, `NOIR`) followed by CRT, NVG, FLIR, and NOIR. That inherited entry
|
||||
> leaves the selected map preset unchanged inside Cockpit. NONE is not offered
|
||||
> in the cycle; CRT, NVG, FLIR, and NOIR temporarily override that preset, while returning to it or exiting
|
||||
> Cockpit restores the captured map style and its exact shader intensities.
|
||||
> The top-center Cockpit vision cycle is the fixed, duplicate-free sequence
|
||||
> Normal, CRT, NVG, FLIR, Anime, Noir, and Snow. Entry selects the map preset
|
||||
> already in use, so a FLIR map opens on the existing FLIR item while
|
||||
> Normal remains available exactly once. NONE is not offered. Cockpit choices
|
||||
> temporarily override the map preset, while Exit Cockpit and Reset both restore
|
||||
> the captured map style and its exact shader intensities.
|
||||
> Selecting a Cockpit vision treatment with configurable parameters opens
|
||||
> Cockpit Display and reveals those parameters through the existing right-side
|
||||
> accordion; an inherited parameterless Normal preset does not force it open.
|
||||
@@ -2880,15 +2911,15 @@ 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/opensky` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) |
|
||||
| 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 |
|
||||
| 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 + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| 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) + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
@@ -3612,7 +3643,7 @@ silently demoting every later lookup for the session.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/ais-live/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/opensky-track` (OAuth, own credit bucket) and `/api/adsblol/trace` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol).
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
### Optional Overpass configuration
|
||||
@@ -3824,13 +3855,14 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
first, activates the Contacts sub-view, and returns the settled 250 km window.
|
||||
Its `aircraft` count is the exact civilian-plus-military total when both feeds
|
||||
can answer, or `unknown` when either component is unavailable.
|
||||
- Cockpit's top vision switch cycles five rendered looks: the inherited map
|
||||
style, CRT, NVG, FLIR, and Noir. There is no empty `NONE` entry.
|
||||
- Cockpit's top vision switch cycles all seven map looks: Normal, CRT, NVG,
|
||||
FLIR, Anime, Noir, and Snow. Entry selects the matching map style without adding
|
||||
a duplicate carousel item. There is no empty `NONE` entry.
|
||||
|
||||
### Live AIS Vessels (June 2026)
|
||||
|
||||
- Server-side `ws` websocket to `wss://stream.aisstream.io/v0/stream` maintained by Vite middleware; `AISSTREAM_API_KEY` never reaches the browser (AISStream has no browser CORS). The `ws` package is used rather than Node's built-in WebSocket specifically because only it can hard-abort a wedged socket (see the watchdog note in the delta block at the top).
|
||||
- Browser polls same-origin `/api/ais-live` cache every 60s.
|
||||
- Browser polls same-origin `/api/vessels` cache every 60s.
|
||||
- The first enable in a session starts one 30-second client grace timer. Until
|
||||
an accepted vessel position arrives, `live`/`open`/`connecting` transport reports
|
||||
`LOADING`; the timer is not restarted by the 60-second poll. Expiry or a
|
||||
@@ -4103,13 +4135,13 @@ are omitted rather than framing the wrong part of the globe.
|
||||
- Proxy error payloads are sanitized (no internal error details returned to clients).
|
||||
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
|
||||
- `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`.
|
||||
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache.
|
||||
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` cache.
|
||||
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
|
||||
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
|
||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||
- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
||||
- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
||||
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
||||
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
|
||||
|
||||
@@ -4196,7 +4228,7 @@ are omitted rather than framing the wrong part of the globe.
|
||||
- **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 Scenes 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.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over the inherited map preset, `CRT`, `NVG`, `FLIR`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. The inherited entry is named directly, such as `NOIR`, and retains that map shader. There is no empty `NONE` entry. CRT, NVG, FLIR, and NOIR temporarily activate the existing Cesium post-process stages, while returning to the inherited entry or exiting Cockpit restores the pre-entry visual style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
|
||||
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
|
||||
- **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap.
|
||||
|
||||
+27
-16
@@ -125,13 +125,18 @@ the host to open the link (`ui/open-link`).
|
||||
Hosts serve panels from their own sites and may refuse other addresses;
|
||||
Codex, for one, refuses any address on the user's machine. So the panel
|
||||
never requests the app's server itself. It loads everything from the app's
|
||||
own paths through `panel_request`, a tool only the panel may call
|
||||
(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the
|
||||
app's server and returns the response, compressed and in parts when large.
|
||||
own paths through `panel_request`, a tool meant for the panel: it is
|
||||
marked `_meta.ui.visibility: ["app"]`, and each call must carry the key the
|
||||
MCP server puts in its panel page. Any client can read that page, so the key
|
||||
keeps the tool from clients that only list it, and is not access control. The
|
||||
MCP server requests the path from the app's server and returns the response,
|
||||
compressed and in parts when large.
|
||||
The same path works in every host. Only
|
||||
map imagery, tiles and fonts load directly, from the providers the
|
||||
resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes
|
||||
provider keys.
|
||||
resource's `csp` lists. `panel_request` refuses Provider Settings
|
||||
(`/api/setup`), credential and model endpoints (`/api/realtime`,
|
||||
`/api/openai`), `/mcp` and the development server's internal routes, in any
|
||||
letter case, encoding or dot suffix.
|
||||
|
||||
The panel loads the app's panel build, which `npm run build:panel` writes to
|
||||
`dist/panel` and the servers serve at `/panel/`: one app script, one
|
||||
@@ -164,14 +169,15 @@ The app applies it through its own actions (style, map, exactly the view's
|
||||
layers, annotations, then the followed entity, retried until its layer has
|
||||
it, or the camera when nothing is followed or the entity is not there yet,
|
||||
since a camera flight would end the follow; then cockpit view when asked) and
|
||||
answers `{ type: 'gev:view-applied', id, ok, steps }`. It
|
||||
posts `{ type: 'gev:ready' }` once it can take views, and only its parent
|
||||
page can send them. See `src/app/embed.js`.
|
||||
answers `{ type: 'gev:view-applied', id, ok, steps }` to the origin that
|
||||
sent the view. It posts `{ type: 'gev:ready' }` once it can take views, and
|
||||
only its parent page can send them. See `src/app/embed.js`.
|
||||
|
||||
The development and preview servers let other pages frame embed-mode
|
||||
documents only; every other document keeps `X-Frame-Options: DENY` and
|
||||
`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages
|
||||
may frame them (any by default).
|
||||
No page may frame the app by default: every document keeps
|
||||
`X-Frame-Options: DENY` and `frame-ancestors 'none'`. Setting
|
||||
`GEV_EMBED_FRAME_ANCESTORS` lets the pages it names (CSP frame-ancestors
|
||||
sources, or `*` for any page) frame embed-mode documents only. The MCP Apps
|
||||
panel loads the app into its own page and needs no framing.
|
||||
|
||||
Answers that have something to show include `data.view`: the view that
|
||||
shows them, with the matching layers on, an area framed from above, and a
|
||||
@@ -240,10 +246,15 @@ claude mcp add --transport http gods-eye-view http://localhost:4173/mcp
|
||||
```
|
||||
|
||||
The route accepts only requests from this machine that name a loopback host
|
||||
and, when a browser sends an `Origin`, come from a loopback origin. This is
|
||||
local transport safety, not authentication. The
|
||||
local server makes no requests other than to the app's `/api` routes and the
|
||||
public feeds the sources already use.
|
||||
on the port they reached and, when a browser sends an `Origin`, come from
|
||||
that same host. It refuses requests a proxy forwarded and refuses all
|
||||
requests while launcher sharing is on. This is local transport safety, not
|
||||
authentication. The
|
||||
local server's tools request the app's `/api` routes and the public feeds
|
||||
the sources already use. The panel's `panel_request` also requests the app's
|
||||
own files and data routes for the panel: it requires the key in the panel's
|
||||
page, and refuses Provider Settings, credential and model endpoints, `/mcp`
|
||||
and the development server's internal routes. See SECURITY.md.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenSky Auth Setup
|
||||
|
||||
God's Eye View uses explicit auth modes for `/api/opensky`:
|
||||
God's Eye View uses explicit auth modes for `/api/flights`:
|
||||
|
||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
||||
@@ -54,7 +54,7 @@ OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
||||
Inspect proxy headers:
|
||||
|
||||
```bash
|
||||
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
|
||||
curl -si http://localhost:4173/api/flights | grep -iE 'HTTP/|X-OpenSky-Auth'
|
||||
```
|
||||
|
||||
Useful reasons:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Panel surface contract
|
||||
|
||||
`src/ui/styles/panel-surfaces.css` supplies an opt-in visual surface for new
|
||||
panels. It uses the application's default color, font, border and radius tokens;
|
||||
Cyber supplies its existing red/slate palette, monospace typography and shaped
|
||||
frame. This is CSS shared by the existing panel system, not a panel registry or
|
||||
a controller. A panel ID does not need a new theme selector.
|
||||
|
||||
## Markup
|
||||
|
||||
A rail panel uses an outer owner with its existing stable `id`, matching
|
||||
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
|
||||
surface. Keep the header and any outward popup outside the scrolling body:
|
||||
|
||||
```html
|
||||
<section
|
||||
id="example-panel"
|
||||
data-panel-id="example-panel"
|
||||
class="panel-collapsible"
|
||||
>
|
||||
<div data-panel-surface>
|
||||
<header data-panel-header>
|
||||
<h2 class="panel-title panel-surface-title">Example</h2>
|
||||
<button
|
||||
class="panel-collapse-btn panel-surface-control"
|
||||
data-collapse-target="example-panel"
|
||||
aria-expanded="true"
|
||||
aria-label="Collapse Example"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</header>
|
||||
<div data-panel-body data-rail-scroller>
|
||||
<label>Filter <input class="panel-surface-control" type="text" /></label>
|
||||
<button class="panel-surface-control" type="button">Apply</button>
|
||||
<p class="panel-surface-error" role="status" hidden></p>
|
||||
</div>
|
||||
<aside data-panel-popup hidden aria-label="Example help">
|
||||
<!-- The panel owns this popup's placement and interactions. -->
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
Use native controls and accessible names. `.panel-surface-control` is an
|
||||
optional skin for buttons, text fields, selects and textareas; it provides
|
||||
hover, focus, disabled and invalid-state styling. Native `disabled` owns actual
|
||||
disablement; `aria-disabled` alone only describes a state and must be enforced
|
||||
by the controller. `.panel-surface-error` supplies error color, not announcement
|
||||
or validation behavior. Keep checkbox/radio semantics and labels intact.
|
||||
|
||||
`data-panel-header` stays outside `data-panel-body`, so it remains reachable
|
||||
when content scrolls. Give the body a bounded height through its containing
|
||||
surface. The rail's allocation supplies that bound; a dock or dialog owner must
|
||||
supply its own `max-height` or height. Avoid a flex body whose contents shrink to
|
||||
fit when those contents should instead scroll.
|
||||
|
||||
## Placement and ownership
|
||||
|
||||
| Mount | Shared contract | Owner responsibilities |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Direct child of `#left-panel-stack` | An owner with a direct `[data-panel-surface]` child receives relative positioning and allocated height; inner surface/body skin | Add the wrapper shown above, use the existing layout/disclosure owners, and retain left-rail focus/auto-collapse rules. |
|
||||
| Direct child of `#right-context-rail` | The same hooks with right-rail allocation and intrinsic measurement | Keep `data-rail-scroller` on the body so measurement preserves its scroll position. Respect Cyber's one-expanded-panel policy. |
|
||||
| Surface inside `#command-dock` | Surface, controls, popup decoration and inherited dock concealment | Supply geometry, stacking, disclosure, pin behavior if wanted, safe viewport bounds and dismissal. This CSS does not extend the existing Location/Visual Presets tray stack manager to arbitrary panels. |
|
||||
| `dialog[data-panel-surface]` | The same surface/body skin; a closed dialog remains hidden | Set width/max-height, use `showModal()` or `show()` and `close()`, and own labels, initial/return focus and dismissal. Do not put `panel-collapsible` on a dialog. |
|
||||
|
||||
For rails, `--panel-expanded-width` and `--panel-collapsed-width` customize
|
||||
the desired widths within the rail; otherwise Cyber uses its existing left,
|
||||
right and collapsed panel widths. `--left-panel-allocated-height` and
|
||||
`--right-panel-allocated-height` belong
|
||||
to the existing layout owner, not to individual panels. The surface must be
|
||||
the wrapper's first non-glow child because left-rail intrinsic measurement
|
||||
inspects that child. Do not put an absolutely positioned popup before the
|
||||
surface or inside its body. Position a popup beside or above its surface using
|
||||
component CSS; the shared rules deliberately do not choose a direction that
|
||||
could run beyond the viewport.
|
||||
|
||||
Rail measurement still considers visible child extents. A popup extending
|
||||
below the surface can therefore increase its measured height. Prefer a popup
|
||||
beside or above the surface, or integrate an external overlay host with the
|
||||
feature's layout owner; this CSS does not change measurement algorithms.
|
||||
|
||||
At viewport widths of 720px or less, both rails are intentional scroll
|
||||
containers. Their overflow can clip outward popups even when the panel's own
|
||||
surface is unclipped. Use a popup contained within the rail's visible bounds,
|
||||
or an explicitly owned overlay host outside the rail; the surface contract
|
||||
does not provide automatic popup escape from these small-screen scroll regions.
|
||||
|
||||
For dock disclosure, use a semantic button with `.panel-surface-control` and
|
||||
the feature's own accessible name, `aria-expanded` state and event binding.
|
||||
Do not copy the rail example's `.panel-collapse-btn` class into the dock:
|
||||
existing dock rules intentionally hide that class. The generic surface skin
|
||||
does not replace the dock's disclosure policy.
|
||||
|
||||
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
|
||||
at initialization and routes state through `setPanelCollapsed`. For additions
|
||||
present at startup, keep those hooks and the existing chrome lifecycle. A
|
||||
dynamically mounted panel must bind and dispose its own disclosure using
|
||||
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
|
||||
the application owning state changes. Do not rerun the whole application chrome
|
||||
initializer just to add one panel. `layoutLeftPanelRail` and
|
||||
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
|
||||
host owns layout scheduling, observations and collapse callbacks. Dynamic
|
||||
content that changes size may require an owner-scheduled layout pass or an
|
||||
owned `ResizeObserver`; adding CSS attributes does not register new observers.
|
||||
|
||||
Persisted/share-link state, pinning, provider loading, keyboard shortcuts and
|
||||
Cockpit-specific mounting still require deliberate integration with their
|
||||
current owners. This contract does not add entries to named persistence, dock,
|
||||
Cockpit or share-state policies. Dispose listeners, observers and timers,
|
||||
dismiss open popups/dialogs and restore focus before removing a dynamic panel.
|
||||
|
||||
## Decoration, popups and visibility
|
||||
|
||||
The shared surface reserves `::before` for its non-interactive background. The
|
||||
Cyber frame clips this pseudo-element only; the surface stays unclipped with
|
||||
visible overflow. This follows the existing Display and Global Context pattern.
|
||||
Putting the shape on an interactive ancestor clips all descendant popups even
|
||||
if their `z-index` is high. A scrolling body also clips descendants by design,
|
||||
so outward popups must be siblings of the body, or use an explicitly owned
|
||||
overlay host outside every clipping ancestor.
|
||||
|
||||
`data-panel-popup` gives a positioned popup the same decorative and control
|
||||
tokens. It is not a popover API, focus trap, collision solver or top-layer
|
||||
manager. The owner must keep it inside the viewport, above relevant siblings,
|
||||
and reachable by pointer and keyboard. A native modal dialog participates in
|
||||
the browser top layer; a surface in a rail does not. Transform, containment,
|
||||
mask, clipping or stacking rules on an additional ancestor can still obstruct
|
||||
either an ordinary panel or its popup. Arbitrary future markup is not covered.
|
||||
|
||||
`hidden`, a closed native dialog and collapsed bodies/popups remain hidden.
|
||||
Clean UI and recording conceal conforming surfaces, including outward popups;
|
||||
owners must close modal dialogs when entering these modes so an invisible
|
||||
modal cannot leave the application inert. Existing Cockpit rail and dock
|
||||
exclusions remain in force. New left-rail/Cockpit functionality needs an
|
||||
explicit product integration; the surface attribute grants no mode exception.
|
||||
Never use theme styling to alter stored preferences or force hidden content
|
||||
visible.
|
||||
|
||||
## Check an addition
|
||||
|
||||
Use the real application with the default and Cyber themes. Check open,
|
||||
collapsed, hidden, disabled/error, theme round trips, clean UI/recording and
|
||||
the intended Cockpit behavior. Exercise keyboard focus, Escape, pointer input,
|
||||
the last scrolling item, resize without reload and any pin/unpin controls.
|
||||
For an outward popup, test visible pixels and pointer hit-testing outside the
|
||||
surface; a positive bounding box alone does not prove it escaped clipping.
|
||||
Check each mount the addition actually supports. The CSS contract cannot
|
||||
establish provider correctness, disclosure lifecycle or responsive placement
|
||||
without those feature-specific checks.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gods-eye-view",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"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",
|
||||
@@ -255,6 +255,7 @@
|
||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
"./tools/services": "./src/tools/services.js",
|
||||
"./view": "./src/view/index.js"
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"server/standalone/vite.config.js",
|
||||
"vite.config.js",
|
||||
"src/tooling/viteBuild.test.mjs",
|
||||
"src/tooling/allowedHosts.test.mjs",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
"server/providers/aircraft/opensky.js",
|
||||
@@ -76,6 +77,7 @@
|
||||
"server/providers/places/routes.js",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/tooling/placeProviders.test.mjs",
|
||||
"server/providers/common/same-site.js",
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
"server/providers/space/launch-library.js",
|
||||
@@ -520,6 +522,7 @@
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -892,5 +895,6 @@
|
||||
"src/view/index.test.mjs",
|
||||
"src/tools/views.test.mjs",
|
||||
"src/app/embed.test.mjs",
|
||||
"src/tools/globePanel.test.mjs"
|
||||
"src/tools/globePanel.test.mjs",
|
||||
"src/costEndpointThrottle.test.mjs"
|
||||
]
|
||||
@@ -31,6 +31,7 @@
|
||||
"modules": [
|
||||
"build/vite.js",
|
||||
"build/application-html.js",
|
||||
"build/allowedHosts.js",
|
||||
"build/embed-framing.js",
|
||||
"build/panel.js"
|
||||
],
|
||||
@@ -74,6 +75,8 @@
|
||||
"server/providers/common/http.js",
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
@@ -287,6 +290,8 @@
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/sources/rateLimit.js"
|
||||
@@ -301,6 +306,7 @@
|
||||
"server/providers/common/source-root.js",
|
||||
"server/standalone/key-setup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"server/standalone/key-setup-hardening.mjs"
|
||||
],
|
||||
"external": []
|
||||
@@ -385,7 +391,8 @@
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/mapStackChips.js",
|
||||
"src/keySetupCore.mjs"
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -404,6 +411,7 @@
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
@@ -524,6 +532,7 @@
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/locationStatus.js",
|
||||
"src/mapStackChips.js",
|
||||
@@ -862,6 +871,8 @@
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
"src/layers/vessels/policy.js",
|
||||
"src/layers/vessels/queries.js",
|
||||
@@ -1261,6 +1272,7 @@
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
@@ -1506,6 +1518,7 @@
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/keySetup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/alpr/index.js",
|
||||
@@ -1713,7 +1726,9 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
"src/layers/vessels/policy.js",
|
||||
"src/layers/vessels/queries.js",
|
||||
@@ -2383,7 +2398,9 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
"src/layers/vessels/policy.js",
|
||||
"src/layers/vessels/queries.js",
|
||||
@@ -2732,6 +2749,7 @@
|
||||
"exports": ["./layers/vessels/ingestion"],
|
||||
"modules": [
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/recordPolicy.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -3157,6 +3175,15 @@
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"tools-panel": {
|
||||
"exports": ["./tools/panel"],
|
||||
"modules": [
|
||||
"src/tools/panel.js",
|
||||
"src/tools/globePanel.js",
|
||||
"src/app/globePanelRuntime.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"tools-services": {
|
||||
"exports": ["./tools/services"],
|
||||
"modules": [
|
||||
|
||||
@@ -253,19 +253,19 @@ async function main() {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== APP_ORIGIN) return void request.continue();
|
||||
// Silence the live feeds: the injected field is the only population.
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
if (url.pathname === '/api/vessels') {
|
||||
return void request.respond(stubJson({
|
||||
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
|
||||
newestPositionAt: null, lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
if (url.pathname === '/api/adsblol/mil') {
|
||||
if (url.pathname === '/api/military') {
|
||||
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky') {
|
||||
if (url.pathname === '/api/flights') {
|
||||
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky-track') {
|
||||
if (url.pathname === '/api/flights/track') {
|
||||
return void request.respond(stubJson({ path: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
|
||||
+103
-39
@@ -42,7 +42,7 @@ page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
page.on('response', (response) => {
|
||||
const url = new URL(response.url());
|
||||
const expectedOptionalTrackMiss = response.status() === 404
|
||||
&& url.pathname === '/api/opensky-track';
|
||||
&& url.pathname === '/api/flights/track';
|
||||
if (
|
||||
url.origin === new URL(appUrl).origin
|
||||
&& response.status() >= 400
|
||||
@@ -108,7 +108,7 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') {
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/vessels') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -124,7 +124,7 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/adsblol/mil') {
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/military') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -132,7 +132,7 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky-track') {
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights/track') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -832,7 +832,7 @@ try {
|
||||
await readinessPage.setRequestInterception(true);
|
||||
readinessPage.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') {
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/flights') {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
request.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
@@ -1572,6 +1572,10 @@ try {
|
||||
const cockpit = manager.cockpitView;
|
||||
const current = document.getElementById('cockpit-vision-current');
|
||||
const label = document.getElementById('cockpit-vision-current-label');
|
||||
const settle = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const intensities = () => Object.fromEntries(
|
||||
Object.entries(manager.stages || {}).map(([name, stage]) => [name, stage.uniforms?.intensity]),
|
||||
);
|
||||
const read = () => ({
|
||||
mode: current?.dataset.cockpitVision,
|
||||
label: label?.textContent?.trim(),
|
||||
@@ -1582,62 +1586,106 @@ try {
|
||||
parametersCollapsed: document.getElementById('param-slider-panel')?.classList.contains('collapsed'),
|
||||
signalCollapsed: cockpit.signalCollapsed,
|
||||
focusRetained: document.activeElement === current,
|
||||
activeStyle: manager.activeStyle,
|
||||
intensities: intensities(),
|
||||
});
|
||||
const reenter = async () => {
|
||||
cockpit.exit({ restoreTracking: true });
|
||||
await settle();
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
if (cockpit.enter() === true) return true;
|
||||
await settle(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
cockpit.setVisionMode('optical');
|
||||
current.focus({ preventScroll: true });
|
||||
const states = [read()];
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
current.click();
|
||||
states.push(read());
|
||||
}
|
||||
const originalStyle = manager.activeStyle;
|
||||
// A Noir map preset: Cockpit enters on Noir, Normal clears every shader
|
||||
// without changing the map preset, and Exit Cockpit restores Noir.
|
||||
cockpit.exit({ restoreTracking: true });
|
||||
manager.setStyle('noir');
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await settle();
|
||||
const enteredOnNoir = await reenter();
|
||||
const entryNoir = read();
|
||||
cockpit.setVisionMode('optical');
|
||||
const inheritedNoir = read();
|
||||
cockpit.setVisionMode('noir');
|
||||
const explicitNoir = read();
|
||||
cockpit.setVisionMode('optical');
|
||||
const restoredNoir = {
|
||||
...read(),
|
||||
activeStyle: manager.activeStyle,
|
||||
intensity: manager.stages.noir?.uniforms?.intensity,
|
||||
};
|
||||
const normalInsideNoir = read();
|
||||
cockpit.setVisionMode('crt');
|
||||
const cockpitOnlyCrt = read();
|
||||
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
||||
await settle();
|
||||
const restoredNoir = { ...read(), cockpitActive: cockpit.active };
|
||||
manager.setStyle(originalStyle);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await settle();
|
||||
const reentered = await reenter();
|
||||
cockpit.setVisionMode('optical');
|
||||
return { states, inheritedNoir, explicitNoir, restoredNoir };
|
||||
return {
|
||||
states,
|
||||
enteredOnNoir,
|
||||
entryNoir,
|
||||
normalInsideNoir,
|
||||
cockpitOnlyCrt,
|
||||
exitedNoir,
|
||||
restoredNoir,
|
||||
reentered,
|
||||
};
|
||||
});
|
||||
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
|
||||
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
|
||||
check(
|
||||
'Cockpit vision control exposes inherited, CRT, NVG, FLIR, and NOIR before wrapping',
|
||||
JSON.stringify(visionCycle.states.map((state) => state.mode))
|
||||
=== JSON.stringify(['optical', 'crt', 'nvg', 'thermal', 'noir', 'optical'])
|
||||
&& visionCycle.states[4]?.label === 'NOIR'
|
||||
&& visionCycle.states[4]?.aria === 'Current cockpit vision style: Noir. Activate for next style.',
|
||||
JSON.stringify(visionCycle),
|
||||
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping',
|
||||
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
|
||||
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
|
||||
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
|
||||
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
|
||||
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
|
||||
);
|
||||
check(
|
||||
'user vision changes open Display for every temporary treatment and retain selector focus',
|
||||
visionCycle.states[1]?.mode === 'crt'
|
||||
&& visionCycle.states.slice(1, 5).every((state) => state.displayOpen === 'true')
|
||||
&& visionCycle.states.slice(1, 5).every((state) => state.radioOpen === 'false')
|
||||
&& visionCycle.states.slice(1, 5).every((state) => state.parametersActive)
|
||||
&& visionCycle.states.slice(1, 5).every((state) => !state.parametersCollapsed)
|
||||
&& visionCycle.states.slice(1, 5).every((state) => state.signalCollapsed)
|
||||
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true')
|
||||
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
|
||||
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
|
||||
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
|
||||
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
|
||||
&& visionCycle.states.every((state) => state.focusRetained),
|
||||
JSON.stringify(visionCycle.states),
|
||||
);
|
||||
check(
|
||||
'inherited Noir and explicit Noir keep distinct ownership and restore the Noir map preset',
|
||||
visionCycle.inheritedNoir?.mode === 'optical'
|
||||
&& visionCycle.inheritedNoir?.label === 'NOIR'
|
||||
&& visionCycle.explicitNoir?.mode === 'noir'
|
||||
&& visionCycle.explicitNoir?.label === 'NOIR'
|
||||
&& visionCycle.restoredNoir?.mode === 'optical'
|
||||
'each Cockpit vision mode drives only its own shader and Normal clears them all',
|
||||
visionCycle.states.slice(1, 7).every((state) => {
|
||||
const target = {
|
||||
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow',
|
||||
}[state.mode];
|
||||
return Object.entries(state.intensities).every(([name, value]) =>
|
||||
name === target ? value > 0 : value === 0);
|
||||
})
|
||||
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0),
|
||||
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
|
||||
);
|
||||
check(
|
||||
'Cockpit enters on the Noir map preset, Normal clears it, and Exit Cockpit restores it',
|
||||
visionCycle.enteredOnNoir
|
||||
&& visionCycle.entryNoir?.mode === 'noir'
|
||||
&& visionCycle.entryNoir?.label === 'NOIR'
|
||||
&& visionCycle.normalInsideNoir?.mode === 'optical'
|
||||
&& visionCycle.normalInsideNoir?.label === 'NORMAL'
|
||||
&& visionCycle.normalInsideNoir?.activeStyle === 'noir'
|
||||
&& Object.values(visionCycle.normalInsideNoir?.intensities || {}).every((value) => value === 0)
|
||||
&& visionCycle.cockpitOnlyCrt?.mode === 'crt'
|
||||
&& visionCycle.cockpitOnlyCrt?.activeStyle === 'noir'
|
||||
&& visionCycle.exitedNoir
|
||||
&& !visionCycle.restoredNoir?.cockpitActive
|
||||
&& visionCycle.restoredNoir?.activeStyle === 'noir'
|
||||
&& visionCycle.restoredNoir?.intensity === 1,
|
||||
&& visionCycle.restoredNoir?.intensities?.noir === 1
|
||||
&& visionCycle.restoredNoir?.intensities?.retro === 0
|
||||
&& visionCycle.reentered,
|
||||
JSON.stringify(visionCycle),
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('noir'));
|
||||
@@ -2266,10 +2314,16 @@ try {
|
||||
// Both the UI and compatibility facade delegate to this navigation owner.
|
||||
const manager = window.__godsEyeView.styleManager._locationNavigation;
|
||||
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const styleBefore = styleManager.activeStyle;
|
||||
// A Cockpit-only choice that Reset must undo.
|
||||
styleManager.cockpitView.setVisionMode('snow');
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
original: manager.resetToGlobeView,
|
||||
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
|
||||
styleBefore,
|
||||
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
|
||||
};
|
||||
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
|
||||
window.__qaCockpitReset.calls += 1;
|
||||
@@ -2305,16 +2359,26 @@ try {
|
||||
resetHidden: document.getElementById('cockpit-reset-globe')?.hidden,
|
||||
height,
|
||||
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
|
||||
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
|
||||
snowDuringCockpit: qa.snowDuringCockpit,
|
||||
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
|
||||
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
|
||||
name === gev.styleManager.activeStyle
|
||||
? stage.uniforms?.intensity > 0
|
||||
: stage.uniforms?.intensity === 0),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'keyboard Cockpit Reset uses one canonical route and preserves Contact selection',
|
||||
'keyboard Cockpit Reset uses one canonical route, preserves Contact selection and restores the map style',
|
||||
resetState.calls === 1
|
||||
&& !resetState.cockpitActive
|
||||
&& !resetState.trackedEntity
|
||||
&& resetState.resetHidden
|
||||
&& Math.abs(resetState.height - 18_000_000) < 150_000
|
||||
&& resetState.subjectPreserved,
|
||||
&& resetState.subjectPreserved
|
||||
&& resetState.snowDuringCockpit > 0
|
||||
&& resetState.styleRestored
|
||||
&& resetState.visionRestored,
|
||||
JSON.stringify(resetState),
|
||||
);
|
||||
check('runtime console remains clean', consoleErrors.length === 0 && localHttpErrors.length === 0,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* least 60 m above the rendered surface under it and under the view centre.
|
||||
*
|
||||
* Contacts: serves two synthetic aircraft through the page's own
|
||||
* /api/opensky response (one flying north over Camp Mabry at 2,500 m, one
|
||||
* /api/flights response (one flying north over Camp Mabry at 2,500 m, one
|
||||
* near Fort Cavazos) and an empty military feed, starts from a wide Texas
|
||||
* view, and presses CONTACTS as a user would. Asserts the Mapped
|
||||
* installations row lists at least one site with a distance within 15 s,
|
||||
@@ -110,7 +110,7 @@ async function openPage({ aircraft = false } = {}) {
|
||||
} catch {
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (url.origin === location.origin && url.pathname === '/api/opensky') {
|
||||
if (url.origin === location.origin && url.pathname === '/api/flights') {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const seconds = (Date.now() - started) / 1000;
|
||||
const lat = 30.29 + (seconds * 120) / 111_320;
|
||||
@@ -159,7 +159,7 @@ async function openPage({ aircraft = false } = {}) {
|
||||
};
|
||||
return Promise.resolve(Response.json(json));
|
||||
}
|
||||
if (url.origin === location.origin && url.pathname === '/api/adsblol/mil')
|
||||
if (url.origin === location.origin && url.pathname === '/api/military')
|
||||
return Promise.resolve(
|
||||
Response.json({ ac: [], now: Date.now(), total: 0 }),
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* honoring the politeness bounds.
|
||||
*
|
||||
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
|
||||
* - /api/opensky → 12 straight-flying planes, category 0
|
||||
* - /api/flights → 12 straight-flying planes, category 0
|
||||
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
|
||||
* F16, GLID, B77W, A320, B738, PC12, R44) + one
|
||||
* found:false miss; responses are HELD until the
|
||||
@@ -260,14 +260,14 @@ async function main() {
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/military/track')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
if (url.includes('/api/military')) {
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
if (url.includes('/api/flights')) {
|
||||
const states = S.planes.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* upstream failures so the run is deterministic and never depends on live
|
||||
* AISStream / CelesTrak availability:
|
||||
*
|
||||
* (i) AIS invalid key — /api/ais-live is answered with
|
||||
* (i) AIS invalid key — /api/vessels is answered with
|
||||
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
|
||||
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
|
||||
*
|
||||
@@ -193,10 +193,12 @@ async function main() {
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
// (i) AIS invalid-key: the exact payload the spec calls for.
|
||||
if (url.includes('/api/ais-live') && !url.includes('/track')) {
|
||||
if (url.includes('/api/vessels') && !url.includes('/track')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
// The bundled server names its vessel provider; attribution reads it.
|
||||
headers: { 'X-Feed-Source': 'AISStream' },
|
||||
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -89,7 +89,7 @@ await page.evaluateOnNewDocument((site, icao) => {
|
||||
window.__TERRAIN.ok += 1;
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky') {
|
||||
if (app && url.pathname === '/api/flights') {
|
||||
const c = window.__CONTACT;
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
|
||||
@@ -99,7 +99,7 @@ await page.evaluateOnNewDocument((site, icao) => {
|
||||
null, true, 1.5, 90, 0, null, null, null, false, 0]],
|
||||
}));
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
|
||||
if (app && url.pathname === '/api/flights/track') return Promise.resolve(json({ path: [] }));
|
||||
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -268,11 +268,11 @@ async function installSyntheticFetches(page) {
|
||||
}));
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
||||
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
||||
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
if (url.includes('/api/flights/track')) return json({ path: [] });
|
||||
if (url.includes('/api/military/track')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
||||
if (url.includes('/api/flights')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
if (url.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
|
||||
if (url.includes('/api/vessels')) return json({ status: 'open', rows: [] });
|
||||
return realFetch(input, init);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -525,11 +525,11 @@ async function main() {
|
||||
const nowSec = Date.now() / 1000 + (T.timeOffsetSec || 0);
|
||||
const tRel = nowSec - T.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/military/track')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
if (url.includes('/api/flights')) {
|
||||
T.__hitsGuard = ++window.__TURN_HITS.opensky;
|
||||
const row = (id, callsign, s, altM, category) => [
|
||||
id, callsign, 'Synthetica',
|
||||
@@ -548,7 +548,7 @@ async function main() {
|
||||
if (T.heli65.active) states.push(row(T.heli65.icao, T.heli65.callsign, T.stateAt(T.heli65, tRel), T.heli65.altM, T.heli65.category));
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
if (url.includes('/api/military')) {
|
||||
window.__TURN_HITS.mil++;
|
||||
const ac = T.military.map((m) => {
|
||||
const s = T.stateAt(m, tRel);
|
||||
|
||||
+10
-10
@@ -738,9 +738,9 @@ check({
|
||||
});
|
||||
|
||||
check({
|
||||
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/opensky)',
|
||||
id: 'B2', group: 'B', desc: 'Flights proxy returns live contacts (/api/flights)',
|
||||
run: async () => {
|
||||
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
|
||||
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
|
||||
if (!r.ok) return fail(`HTTP ${r.status}: ${r.text.slice(0, 120)}`);
|
||||
const n = r.json?.states?.length || 0;
|
||||
return n > 0 ? pass(`${n} states, cache=${r.headers.get('x-opensky-cache') || 'n/a'}`) : fail('0 states returned');
|
||||
@@ -750,7 +750,7 @@ check({
|
||||
check({
|
||||
id: 'B3', group: 'B', desc: 'OpenSky credentials are actually in use (not the anonymous/fallback path)',
|
||||
run: async () => {
|
||||
const r = await jget('/api/opensky?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
|
||||
const r = await jget('/api/flights?lamin=24&lomin=-125&lamax=50&lomax=-66', { timeoutMs: 45000 });
|
||||
// Header names are exact: X-OpenSky-Auth-Mode-Used / X-OpenSky-Auth-Reason.
|
||||
// (An earlier guess at these names made this check pass vacuously.)
|
||||
const reason = r.headers.get('X-OpenSky-Auth-Reason') || '';
|
||||
@@ -826,9 +826,9 @@ check({
|
||||
});
|
||||
|
||||
check({
|
||||
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/ais-live)', needsKey: 'AIS',
|
||||
id: 'B8', group: 'B', desc: 'AIS vessel feed is live (/api/vessels)', needsKey: 'AIS',
|
||||
run: async () => {
|
||||
const r = await jget('/api/ais-live', { timeoutMs: 40000 });
|
||||
const r = await jget('/api/vessels', { timeoutMs: 40000 });
|
||||
const rows = r.json?.rows?.length || 0;
|
||||
const status = r.json?.status;
|
||||
if (!r.ok) return fail(`HTTP ${r.status} status=${status}`);
|
||||
@@ -864,7 +864,7 @@ check({
|
||||
const guard = keyGuard('AIS', env.keys.AIS);
|
||||
if (guard) return guard;
|
||||
if (env.keys.AIS === true) return skip('server HAS an AISStream key', 'N/A');
|
||||
const r = await jget('/api/ais-live');
|
||||
const r = await jget('/api/vessels');
|
||||
return r.status === 503 && r.json?.status === 'missing-key' && Array.isArray(r.json?.rows)
|
||||
? pass(`503 status=missing-key, rows=[] — "${String(r.json?.error).slice(0, 60)}"`)
|
||||
: fail(`expected 503/missing-key, got ${r.status} ${r.text.slice(0, 120)}`);
|
||||
@@ -1022,7 +1022,7 @@ check({
|
||||
check({
|
||||
id: 'B21', group: 'B', desc: 'No proxy echoes credential material back to the client (P1-5 acceptance #4)',
|
||||
run: async () => {
|
||||
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/ais-live'];
|
||||
const paths = ['/api/cctv/sources', '/api/tomtom/status', '/api/firms/status', '/api/celestrak/stations', '/api/vessels'];
|
||||
const leaked = [];
|
||||
const unscannable = [];
|
||||
for (const p of paths) {
|
||||
@@ -1036,7 +1036,7 @@ check({
|
||||
// An error page is not a payload. Scanning five 500s and finding no key
|
||||
// is trivially true and proves nothing — a broken app must not satisfy a
|
||||
// negative assertion. The one documented exception is the keyless
|
||||
// 503 {status:'missing-key'} from /api/ais-live, which IS its real shape.
|
||||
// 503 {status:'missing-key'} from /api/vessels, which IS its real shape.
|
||||
const documentedKeyless = r.status === 503
|
||||
&& (r.json?.status === 'missing-key' || r.json?.error === 'no_key' || /OPENAI_API_KEY is not set/.test(r.text));
|
||||
if (!r.ok && !documentedKeyless) {
|
||||
@@ -2071,13 +2071,13 @@ async function preflight() {
|
||||
env.keys.FIRMS = await statusKey('/api/firms/status');
|
||||
env.keys.TOMTOM = await statusKey('/api/tomtom/status');
|
||||
try {
|
||||
const ais = await jget('/api/ais-live');
|
||||
const ais = await jget('/api/vessels');
|
||||
if (ais.status === 503 && ais.json?.status === 'missing-key') env.keys.AIS = false;
|
||||
else if (ais.status === 200 && ais.json && Array.isArray(ais.json.rows)) env.keys.AIS = true;
|
||||
else env.keys.AIS = 'error';
|
||||
} catch { env.keys.AIS = 'error'; }
|
||||
try {
|
||||
const os = await jget('/api/opensky?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
|
||||
const os = await jget('/api/flights?lamin=29&lomin=-99&lamax=31&lomax=-97', { timeoutMs: 40000 });
|
||||
const reason = os.headers.get('X-OpenSky-Auth-Reason') || '';
|
||||
const used = os.headers.get('X-OpenSky-Auth-Mode-Used') || os.headers.get('X-OpenSky-Auth') || '';
|
||||
if (!os.ok) env.keys.OPENSKY = 'error';
|
||||
|
||||
@@ -180,7 +180,7 @@ async function installDeterministicDevEndpoints(page) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
if (url.pathname === '/api/vessels') {
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -219,7 +219,7 @@ async function main() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/ais-live') {
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/vessels') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -229,11 +229,11 @@ async function main() {
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
if (url.includes('/api/flights/track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/military/track')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
if (url.includes('/api/flights')) {
|
||||
window.__SPR_HITS.opensky++;
|
||||
const states = S.flights.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
@@ -249,7 +249,7 @@ async function main() {
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
if (url.includes('/api/military')) {
|
||||
window.__SPR_HITS.mil++;
|
||||
const ac = S.military.map((m) => {
|
||||
const s = S.stateAt(m, tRel);
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* `fetch` shim in the page that intercepts the poll endpoints and returns
|
||||
* SYNTHETIC aircraft positioned near the camera, in the EXACT upstream payload
|
||||
* shapes the layers parse:
|
||||
* - flights → GET /api/opensky → { states: [ <state-vector[]> ] }
|
||||
* - military → GET /api/adsblol/mil → { ac: [ <adsblol-aircraft{}> ] }
|
||||
* - vessels → GET /api/ais-live → connected, zero-row snapshot
|
||||
* - flights → GET /api/flights → { states: [ <state-vector[]> ] }
|
||||
* - military → GET /api/military → { ac: [ <adsblol-aircraft{}> ] }
|
||||
* - vessels → GET /api/vessels → connected, zero-row snapshot
|
||||
* The shim stays installed so the layers' setInterval pollers keep the
|
||||
* synthetic planes alive (under MISSING_POLL_LIMIT=3 so they're never pruned).
|
||||
*
|
||||
@@ -343,7 +343,7 @@ async function main() {
|
||||
const url = new URL(requestUrl, window.location.href);
|
||||
const isAppRequest = url.origin === appOrigin;
|
||||
// OpenSky (commercial flights): { states: [ state-vector[] ] }
|
||||
if (isAppRequest && url.pathname === '/api/opensky') {
|
||||
if (isAppRequest && url.pathname === '/api/flights') {
|
||||
window.__SYNTH_HITS.opensky++;
|
||||
// A withheld contact models the ordinary case where the recipient's
|
||||
// first authoritative refresh does not yet carry the shared aircraft.
|
||||
@@ -371,11 +371,11 @@ async function main() {
|
||||
// Trail backfill (fires on trackById). Stub with valid-but-empty
|
||||
// payloads so the deterministic run never 404s against the dev proxy.
|
||||
// flights: { path: [ [time, lat, lon, baroAlt, true_track, on_ground] ] }
|
||||
if (isAppRequest && url.pathname === '/api/opensky-track') {
|
||||
if (isAppRequest && url.pathname === '/api/flights/track') {
|
||||
return Promise.resolve(jsonResponse({ path: [] }));
|
||||
}
|
||||
// military: { timestamp: <epochSec>, trace: [ [secAfter, lat, lon, ...] ] }
|
||||
if (isAppRequest && url.pathname === '/api/adsblol/trace') {
|
||||
if (isAppRequest && url.pathname === '/api/military/track') {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(Date.now() / 1000), trace: [] }));
|
||||
}
|
||||
// adsbdb enrichment (fires for tracked/model-eligible planes): empty
|
||||
@@ -388,7 +388,7 @@ async function main() {
|
||||
// Contacts enables the optional AIS source alongside aircraft. Keep
|
||||
// this tracking harness independent of local AIS credentials while
|
||||
// preserving the truthful "awaiting first message" lifecycle.
|
||||
if (isAppRequest && url.pathname === '/api/ais-live') {
|
||||
if (isAppRequest && url.pathname === '/api/vessels') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
status: 'connected',
|
||||
rows: [],
|
||||
@@ -450,7 +450,7 @@ async function main() {
|
||||
return Promise.resolve(jsonResponse({ places: [] }));
|
||||
}
|
||||
// adsb.lol military: { ac: [ aircraft{} ] } (must come AFTER /trace check)
|
||||
if (isAppRequest && url.pathname === '/api/adsblol/mil') {
|
||||
if (isAppRequest && url.pathname === '/api/military') {
|
||||
window.__SYNTH_HITS.mil++;
|
||||
const ac = window.__SYNTH.military.map((m) => ({
|
||||
hex: m.hex,
|
||||
|
||||
+63
-13
@@ -2,20 +2,39 @@
|
||||
* Serves the local MCP server at /mcp on the development and preview servers.
|
||||
*
|
||||
* This is local transport safety, not authentication: requests must come from
|
||||
* this machine, name a loopback host (blocking DNS rebinding) and, when a
|
||||
* browser sends an Origin, come from a loopback origin.
|
||||
* this machine, carry no proxy forwarding headers, arrive while sharing is
|
||||
* off, name a loopback host on the port they arrived at (blocking DNS
|
||||
* rebinding) and, when a browser sends an Origin, come from that same host.
|
||||
*/
|
||||
|
||||
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
|
||||
import { createLocalMcpServer } from './server.js';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
/** How long a client may take to send its request body. */
|
||||
const BODY_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
/** Whether a request may reach the local MCP server. */
|
||||
export function isLocalMcpRequest({ remoteAddress, host, origin }) {
|
||||
/**
|
||||
* Whether a request may reach the local MCP server. `localPort` is the port
|
||||
* the connection arrived at; `headers` are the request's, keyed by lower-case
|
||||
* name; `env` is checked for launcher sharing.
|
||||
*/
|
||||
export function isLocalMcpRequest({
|
||||
remoteAddress,
|
||||
localPort,
|
||||
host,
|
||||
origin,
|
||||
headers = {},
|
||||
env = {},
|
||||
}) {
|
||||
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
|
||||
// A proxy or tunnel in front of the server can make a remote request look
|
||||
// local, and sharing exposes the server beyond this machine.
|
||||
if (hasProxySignals(headers) || isSharingEnabled(env)) return false;
|
||||
let hostUrl;
|
||||
try {
|
||||
hostUrl = new URL(`http://${host}`);
|
||||
@@ -23,12 +42,16 @@ export function isLocalMcpRequest({ remoteAddress, host, origin }) {
|
||||
return false;
|
||||
}
|
||||
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
|
||||
// The Host names the port the request reached, so tools are never pointed
|
||||
// at another local service.
|
||||
if (localPort != null && (hostUrl.port || '80') !== String(localPort))
|
||||
return false;
|
||||
if (origin == null) return true;
|
||||
try {
|
||||
const originUrl = new URL(origin);
|
||||
return (
|
||||
['http:', 'https:'].includes(originUrl.protocol) &&
|
||||
LOOPBACK_HOSTS.has(originUrl.hostname)
|
||||
originUrl.host === hostUrl.host
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -36,7 +59,10 @@ export function isLocalMcpRequest({ remoteAddress, host, origin }) {
|
||||
}
|
||||
|
||||
/** Vite plugin that mounts the local MCP server at /mcp. */
|
||||
export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
|
||||
export function localMcpPlugin({
|
||||
createServer = createLocalMcpServer,
|
||||
bodyTimeoutMs = BODY_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const handlers = new Map();
|
||||
const handlerFor = (apiBase) => {
|
||||
if (!handlers.has(apiBase))
|
||||
@@ -49,8 +75,11 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
|
||||
if (
|
||||
!isLocalMcpRequest({
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
localPort: req.socket?.localPort,
|
||||
host,
|
||||
origin: req.headers.origin,
|
||||
headers: req.headers,
|
||||
env: process.env,
|
||||
})
|
||||
) {
|
||||
res.writeHead(403, {
|
||||
@@ -71,7 +100,10 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
|
||||
};
|
||||
res.on('close', onClose);
|
||||
try {
|
||||
const body = req.method === 'POST' ? await readBody(req) : undefined;
|
||||
const body =
|
||||
req.method === 'POST'
|
||||
? await readBody(req, bodyTimeoutMs)
|
||||
: undefined;
|
||||
const request = new Request(`http://${host}/mcp`, {
|
||||
method: req.method,
|
||||
headers: Object.entries(req.headers).flatMap(([name, value]) =>
|
||||
@@ -86,13 +118,18 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
|
||||
} catch (error) {
|
||||
if (disconnect.signal.aborted) return;
|
||||
if (res.headersSent) return res.destroy();
|
||||
res.writeHead(error?.status === 413 ? 413 : 400, {
|
||||
res.writeHead([408, 413].includes(error?.status) ? error.status : 400, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.status === 413 ? 'Request too large' : 'Bad request',
|
||||
error:
|
||||
error?.status === 413
|
||||
? 'Request too large'
|
||||
: error?.status === 408
|
||||
? 'Request timed out'
|
||||
: 'Bad request',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -107,11 +144,21 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
function readBody(req, timeoutMs = BODY_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
// A client that stops sending must not hold the request open.
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(Object.assign(new Error('Request timed out'), { status: 408 })),
|
||||
timeoutMs,
|
||||
);
|
||||
const fail = (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
};
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
// Keep draining without storing, so the 413 response can still be sent.
|
||||
@@ -121,9 +168,12 @@ function readBody(req) {
|
||||
}
|
||||
tooLarge = true;
|
||||
chunks.length = 0;
|
||||
reject(Object.assign(new Error('Request too large'), { status: 413 }));
|
||||
fail(Object.assign(new Error('Request too large'), { status: 413 }));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
req.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on('error', fail);
|
||||
});
|
||||
}
|
||||
+11
-4
@@ -1,13 +1,16 @@
|
||||
/** The local MCP server: Core's tools over services backed by a running app. */
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
composeCatalog,
|
||||
coreTools,
|
||||
catalogForSurface,
|
||||
} from '../../src/tools/index.js';
|
||||
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
|
||||
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
|
||||
import {
|
||||
createGlobePanelResource,
|
||||
panelRuntime,
|
||||
} from '../../src/tools/panel.js';
|
||||
import { createMcpServer } from '../../src/tools/mcp/index.js';
|
||||
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
|
||||
|
||||
@@ -28,17 +31,21 @@ export function createLocalMcpServer({
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
// Each server makes a new key for its panel. The panel's page carries it
|
||||
// and panel_request requires it; any client may read the page, so it keeps
|
||||
// the tool from clients that only list it, and is not access control.
|
||||
const panelKey = randomBytes(32).toString('base64url');
|
||||
return createMcpServer({
|
||||
catalog: catalogForSurface(
|
||||
composeCatalog({
|
||||
tools: coreTools,
|
||||
services: createLocalToolServices({ apiBase, fetchImpl }),
|
||||
services: createLocalToolServices({ apiBase, fetchImpl, panelKey }),
|
||||
}),
|
||||
'mcp',
|
||||
),
|
||||
name: 'gods-eye-view',
|
||||
version,
|
||||
instructions: INSTRUCTIONS,
|
||||
resources: [createGlobePanelResource({ runtime: panelRuntime })],
|
||||
resources: [createGlobePanelResource({ runtime: panelRuntime, panelKey })],
|
||||
});
|
||||
}
|
||||
@@ -59,5 +59,6 @@ export function createLocalToolServices(options = {}) {
|
||||
return createToolServices({
|
||||
fetchImpl: createApiFetch(options),
|
||||
appUrl: options.apiBase ?? DEFAULT_API_BASE,
|
||||
panelKey: options.panelKey,
|
||||
});
|
||||
}
|
||||
+23
-4
@@ -30,13 +30,18 @@ export function describeRequest(message) {
|
||||
return typeof subject === 'string' ? `${method} ${subject}` : method;
|
||||
}
|
||||
|
||||
/** Serve newline-delimited JSON-RPC until `input` ends. */
|
||||
/**
|
||||
* Serve newline-delimited JSON-RPC until `input` ends. A client's
|
||||
* `notifications/cancelled` aborts the named request, which then gets no
|
||||
* response, as the protocol asks.
|
||||
*/
|
||||
export async function serveStdio(server, { input, output, log = () => {} }) {
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
const pending = new Set();
|
||||
const running = new Map();
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const work = respond(server, line, log).then((response) => {
|
||||
const work = respond(server, line, log, running).then((response) => {
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
});
|
||||
pending.add(work);
|
||||
@@ -45,16 +50,30 @@ export async function serveStdio(server, { input, output, log = () => {} }) {
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async function respond(server, line, log) {
|
||||
async function respond(server, line, log, running) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return parseErrorResponse();
|
||||
}
|
||||
if (message?.method === 'notifications/cancelled') {
|
||||
running.get(message.params?.requestId)?.abort();
|
||||
return null;
|
||||
}
|
||||
const described = describeRequest(message);
|
||||
if (described) log(described);
|
||||
const response = await server.handle(message);
|
||||
const id = message?.id;
|
||||
const cancellable = id !== undefined && id !== null && !running.has(id);
|
||||
const controller = new AbortController();
|
||||
if (cancellable) running.set(id, controller);
|
||||
let response;
|
||||
try {
|
||||
response = await server.handle(message, { signal: controller.signal });
|
||||
} finally {
|
||||
if (cancellable) running.delete(id);
|
||||
}
|
||||
if (controller.signal.aborted) return null;
|
||||
// A failed tool call answers the client, not the log; say why here too.
|
||||
if (response?.result?.isError)
|
||||
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||
*
|
||||
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
|
||||
* Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
|
||||
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
|
||||
* cached body as STALE instead of relaying the failure, and it backs off from
|
||||
* upstream for the Retry-After period (bounded) so a rate limit is never
|
||||
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-ADS-B-Cache': cacheStatus,
|
||||
'X-Feed-Source': 'adsb.lol',
|
||||
'X-Feed-Cache': cacheStatus,
|
||||
...(['HIT', 'STALE'].includes(cacheStatus)
|
||||
? {
|
||||
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
}
|
||||
: {}),
|
||||
...extra,
|
||||
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
|
||||
}
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||
server.middlewares.use('/api/military', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -380,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
||||
*/
|
||||
export function openSkyProxy() {
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||
server.middlewares.use('/api/flights', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
|
||||
/**
|
||||
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
|
||||
*
|
||||
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* own credit bucket, 4 credits per call on the free tier). OAuth via the
|
||||
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
|
||||
* client can fall back to its accumulated trail silently.
|
||||
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
|
||||
* of real history per aircraft. Treat as best-effort; data is ODbL —
|
||||
* credit "adsb.lol (ODbL)" in the UI.
|
||||
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/opensky-track', async (req, res) => {
|
||||
middlewares.use('/api/flights/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const icao24 = String(incoming.searchParams.get('icao24') || '')
|
||||
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
});
|
||||
|
||||
middlewares.use('/api/adsblol/trace', async (req, res) => {
|
||||
middlewares.use('/api/military/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const hex = String(incoming.searchParams.get('hex') || '')
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -92,6 +93,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
|
||||
load: loadDelDOTSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'vegvesen',
|
||||
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
|
||||
load: loadVegvesenSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -236,6 +236,37 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
|
||||
* body cannot be buffered without limit. */
|
||||
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
|
||||
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
|
||||
* country (~900 cameras). The raw DATEX node needs an account; this view does
|
||||
* not. Frames are stills on the agency's camera host. */
|
||||
export const DEFAULT_VEGVESEN_CCTV_URL =
|
||||
'https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000';
|
||||
/** The only origin Vegvesen camera frames may come from. */
|
||||
export const VEGVESEN_IMAGE_ORIGIN =
|
||||
'https://kamera.atlas.vegvesen.no/api/images/';
|
||||
/** Live HLS for the ~140 cameras that publish video; only this exact
|
||||
* per-camera manifest path is registered. */
|
||||
export const VEGVESEN_VIDEO_URL = (cameraId) =>
|
||||
`https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`;
|
||||
/** Above the ~850 working cameras so a default install keeps them all, while
|
||||
* staying close to them: the per-pack defaults are meant to sum to no more
|
||||
* than DEFAULT_CCTV_MAX_SOURCES. */
|
||||
export const DEFAULT_VEGVESEN_MAX_SOURCES = 900;
|
||||
/** Hard ceiling on the catalog body. The whole country is ~800 KB. */
|
||||
export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024;
|
||||
/** Prioritization anchors: the largest cities from south to north, so a cap
|
||||
* keeps national coverage rather than just the capital region. */
|
||||
export const NORWAY_ANCHORS = [
|
||||
{ lat: 59.9139, lon: 10.7522 }, // Oslo
|
||||
{ lat: 60.3913, lon: 5.3221 }, // Bergen
|
||||
{ lat: 58.97, lon: 5.7331 }, // Stavanger
|
||||
{ lat: 63.4305, lon: 10.3951 }, // Trondheim
|
||||
{ lat: 58.1467, lon: 7.9956 }, // Kristiansand
|
||||
{ lat: 67.2804, lon: 14.4049 }, // Bodø
|
||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||
];
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
|
||||
@@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 57.9 &&
|
||||
lat <= 71.3 &&
|
||||
lon >= 4.4 &&
|
||||
lon <= 31.3
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFinlandCoordinate(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
||||
|
||||
@@ -59,6 +59,12 @@ import {
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
VEGVESEN_VIDEO_URL,
|
||||
DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
NORWAY_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -76,6 +82,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
@@ -1701,3 +1708,158 @@ export async function loadDelDOTSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Statens vegvesen `CctvSimple` feature -> one catalog source, or null.
|
||||
*
|
||||
* `orientationDescription` names the place the camera looks towards
|
||||
* ("Svelgen"), not a bearing, so it goes into the label and the heading uses
|
||||
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
|
||||
* Cameras the feed reports as faulty are dropped. Cameras that publish HLS
|
||||
* get live video, with the still as the snapshot fallback.
|
||||
*
|
||||
* @param {object} feature - GeoJSON feature from the OGC items response.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function vegvesenCameraToSource(feature) {
|
||||
if (!feature || typeof feature !== 'object') return null;
|
||||
const props = feature.properties;
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
const availability = String(
|
||||
props['status.stillImageAvailability'] ?? '',
|
||||
).trim();
|
||||
if (availability && availability !== 'videoOrImagesAvailable') return null;
|
||||
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
const lon = toFiniteNumber(coordinates[0]);
|
||||
const lat = toFiniteNumber(coordinates[1]);
|
||||
if (!isLikelyNorwayCoordinate(lat, lon)) return null;
|
||||
|
||||
const rawId = String(props.cameraId ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_-]{1,40}$/.test(rawId)) return null;
|
||||
let image;
|
||||
try {
|
||||
image = new URL(String(props.stillImageUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (image.username || image.password) return null;
|
||||
const imageUrl = image.href;
|
||||
if (imageUrl !== `${VEGVESEN_IMAGE_ORIGIN}${rawId}`) return null;
|
||||
|
||||
const cameraId = `no-vegvesen-${rawId.toLowerCase()}`;
|
||||
const place = String(props.description ?? '').trim() || `Kamera ${rawId}`;
|
||||
const towards = String(props.orientationDescription ?? '').trim();
|
||||
const road = String(props.roadNumber ?? '').trim();
|
||||
const name = towards && towards !== place ? `${place} → ${towards}` : place;
|
||||
// Live video when the camera publishes HLS on the agency's own host; the
|
||||
// still stays the snapshot fallback. CCTV_VEGVESEN_VIDEO=0 keeps stills only.
|
||||
const videoUrl =
|
||||
String(process.env.CCTV_VEGVESEN_VIDEO || '1').trim() !== '0' &&
|
||||
Number(props.videoServiceLevel) > 0 &&
|
||||
String(props.videoEncodingStandard ?? '').toLowerCase() === 'hls' &&
|
||||
String(props.videoUrl ?? '').trim() === VEGVESEN_VIDEO_URL(rawId)
|
||||
? VEGVESEN_VIDEO_URL(rawId)
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: road ? `${road} ${name}` : name,
|
||||
// One country-wide category in the camera picker, like Finland; the
|
||||
// place itself is already in the name.
|
||||
city: 'Norway',
|
||||
cityId: 'norway',
|
||||
provider: 'Statens vegvesen',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
|
||||
// prior for the whole country, while cameras run from sea level to
|
||||
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
|
||||
// The point-height prior and the client's one-shot ground snap correct it
|
||||
// where they resolve; on a stack where neither does (no 3D tiles), a pass
|
||||
// camera stays hundreds of metres below the terrain, the same risk the
|
||||
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
|
||||
// point-height API) would remove it.
|
||||
groundElevationM: 150,
|
||||
feedType: videoUrl ? 'hls' : 'image',
|
||||
url: videoUrl || imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'vegvesen-datex',
|
||||
license:
|
||||
'Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen',
|
||||
code: cameraDisplayCode(place.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Statens vegvesen (Norway) road cameras from the keyless OGC API
|
||||
* Features view of the DATEX 3.1 CCTV table. Frames are stills on
|
||||
* kamera.atlas.vegvesen.no.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadVegvesenSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/geo+json,application/json' },
|
||||
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] Vegvesen catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Vegvesen camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
);
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const feature of features) {
|
||||
const camera = vegvesenCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_VEGVESEN_MAX_SOURCES || DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Vegvesen camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,52 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
|
||||
|
||||
/**
|
||||
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
|
||||
* returns `null` and the caller skips the check entirely — a runtime no-op that
|
||||
* preserves the original behavior. Only a positive integer N enables a fixed
|
||||
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
|
||||
* window state persists across requests). The global backstop is set to a
|
||||
* generous multiple of the per-IP cap so a single host can't starve the rest.
|
||||
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
|
||||
*
|
||||
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
|
||||
* The default is ON. An unset or blank value takes `defaultPerMin`; a positive
|
||||
* integer overrides it; an explicit `0` disables the limiter for deployments
|
||||
* that genuinely want no throttle.
|
||||
*
|
||||
* A value that cannot be read as a number — a typo, a stray unit, a negative —
|
||||
* also takes the default rather than disabling the guard. That asymmetry is the
|
||||
* point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed
|
||||
* server's API key to whoever asks. Removing the throttle stays possible, but
|
||||
* only by saying `0`, which cannot be typed by accident.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {number} Requests/min/IP; `0` means unlimited.
|
||||
*/
|
||||
export function resolvePerMinuteCap(envValue, defaultPerMin) {
|
||||
const raw = typeof envValue === 'string' ? envValue.trim() : envValue;
|
||||
if (raw === undefined || raw === null || raw === '') return defaultPerMin;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin;
|
||||
// Only an explicit 0 disables the guard: a positive fraction such as 0.5
|
||||
// would floor to 0, so it takes the strictest real cap instead.
|
||||
if (parsed > 0 && parsed < 1) return 1;
|
||||
return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
* Returns `null` only when the cap resolves to `0` (the explicit opt-out), in
|
||||
* which case the caller skips the check entirely. Otherwise a fixed 60s window
|
||||
* of N requests/IP, built lazily once and reused so its per-IP window state
|
||||
* persists across requests. The global backstop is a generous multiple of the
|
||||
* per-IP cap so a single host can't starve the rest.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
|
||||
*/
|
||||
export function makeOptInRateLimiter(envValue) {
|
||||
const max = Number(envValue);
|
||||
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
|
||||
export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
const max = resolvePerMinuteCap(envValue, defaultPerMin);
|
||||
if (max <= 0) return null; // explicit opt-out
|
||||
return makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: Math.floor(max),
|
||||
globalMax: Math.floor(max) * 20,
|
||||
max,
|
||||
globalMax: max * 20,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||
|
||||
/**
|
||||
* Cross-site request gate for cost-bearing and log endpoints. Feeds the
|
||||
* request to the pure, unit-tested `admitSameSiteRequest` and answers 403
|
||||
* itself when the request is refused. Returns true when it has already
|
||||
* responded (the caller returns); false when the handler should continue.
|
||||
* Refuses cross-site browser requests (foreign or opaque Origin, a
|
||||
* Sec-Fetch-Site other than same-origin/none, or reverse-proxy headers) while
|
||||
* keeping loopback non-browser tools and the HOST=0.0.0.0 LAN opt-in working.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSite(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: req.headers || {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a middleware handler so cross-site requests are refused with 403
|
||||
* before the handler runs. Loopback tools and same-origin browser requests
|
||||
* pass straight through.
|
||||
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown} handler
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown}
|
||||
*/
|
||||
export function sameSiteGated(handler) {
|
||||
return (req, res, next) => {
|
||||
if (admitSameSite(req, res)) return undefined;
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { handleHudSummary } from './openai/hud-summary.js';
|
||||
import { createDebugLogHandler } from './openai/debug-log.js';
|
||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
import { sameSiteGated } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||
@@ -15,16 +16,20 @@ function openAiRealtimeProxy({
|
||||
realtime = {},
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
// 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));
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
sameSiteGated(createDebugLogHandler({ sourceRoot })),
|
||||
);
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
sameSiteGated(
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,11 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
// The server's own timestamp comes last, so a record cannot supply one.
|
||||
await append(
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
loggedAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
);
|
||||
res.statusCode = 204;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
||||
|
||||
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
||||
// fallback has no provider cost and resolves above without consuming a
|
||||
// paid-endpoint quota slot.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
// Keyless HUD fallback has no provider cost and resolves above without
|
||||
// consuming a paid-endpoint quota slot.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, 64 * 1024);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both
|
||||
* places that already name a value agree on it: `.env.example` recommends 30
|
||||
* and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app
|
||||
* is unaffected and only an unconfigured server changes. The HUD asks for a
|
||||
* summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and
|
||||
* a voice session adds one token mint; the cap clears ordinary use several
|
||||
* times over and bites only a caller working the endpoint far harder than the
|
||||
* app does.
|
||||
*/
|
||||
const OPENAI_DEFAULT_PER_MIN = 30;
|
||||
|
||||
// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later
|
||||
// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading
|
||||
// process.env here at import time would always see them unset and silently stay unlimited even when
|
||||
// process.env here at import time would always see them unset and fall back to the default even when
|
||||
// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env;
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default).
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out.
|
||||
let _openAiRateLimiter;
|
||||
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */
|
||||
function openAiRateLimiter() {
|
||||
if (_openAiRateLimiter === undefined)
|
||||
_openAiRateLimiter = makeOptInRateLimiter(
|
||||
_openAiRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _openAiRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null (unlimited, the default) this is a no-op returning
|
||||
* `true`, so the handler proceeds exactly as before.
|
||||
* Apply the limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
|
||||
* returning `true`, so the handler proceeds unthrottled.
|
||||
*
|
||||
* @param {((key:string)=>boolean)|null} limiter
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
|
||||
*/
|
||||
function enforceOptInRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // unlimited (default) — no behavior change
|
||||
function enforceRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // explicit 0 opt-out — unthrottled
|
||||
if (limiter(clientKey(req))) return true;
|
||||
res.statusCode = 429;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export { enforceOptInRateLimit, openAiRateLimiter };
|
||||
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import {
|
||||
resolveVoiceModel,
|
||||
isKnownVoiceTier,
|
||||
@@ -31,8 +31,8 @@ function createRealtimeTokenHandler({
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
export { installRouteMiddleware } from './places/routes.js';
|
||||
export {
|
||||
makeRateLimiter,
|
||||
makeOptInRateLimiter,
|
||||
makeCostRateLimiter,
|
||||
clientKey,
|
||||
} from './common/rate-limit.js';
|
||||
export { haversineKm } from './common/geo.js';
|
||||
@@ -2,21 +2,35 @@ import {
|
||||
googleServerApiKey,
|
||||
keylessGooglePlacesResponse,
|
||||
} from './google-key.js';
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import {
|
||||
projectNearbyPlaces,
|
||||
projectTextSearchPlaces,
|
||||
} from '../../../src/data/placeProviderPayloads.js';
|
||||
|
||||
// Construct lazily after the standalone environment has loaded.
|
||||
// undefined = not built yet; null = unlimited; fn = active limiter
|
||||
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
|
||||
let _googleRateLimiter;
|
||||
|
||||
/** Google cost endpoint (nearby-places). Null = unlimited (default). */
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
|
||||
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
|
||||
* packaged app keeps behaving exactly as it does today and only an
|
||||
* unconfigured server changes — from unlimited to what the product already
|
||||
* runs with. Places calls are user-driven (a search, a nearby lookup, an
|
||||
* installation probe) rather than polled, so this sits far above what the app
|
||||
* generates and bites only a caller enumerating the endpoint. `.env.example`
|
||||
* suggests a tighter 60 when the host is not localhost; that still applies.
|
||||
*/
|
||||
export const GOOGLE_DEFAULT_PER_MIN = 120;
|
||||
|
||||
/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */
|
||||
function googleRateLimiter() {
|
||||
if (_googleRateLimiter === undefined)
|
||||
_googleRateLimiter = makeOptInRateLimiter(
|
||||
_googleRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _googleRateLimiter;
|
||||
}
|
||||
@@ -50,6 +64,9 @@ export function googlePlacesContextProxy({
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/google/nearby-places', async (req, res) => {
|
||||
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
|
||||
// about this endpoint's method surface.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -80,7 +97,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
|
||||
// `places: []` contract that the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
@@ -161,6 +178,8 @@ export function googlePlacesContextProxy({
|
||||
// a view-biased Text Search lands on the actual feature. Same key, field
|
||||
// mask, throttle, and `places: []` error contract as nearby-places above.
|
||||
middlewares.use('/api/google/text-search', async (req, res) => {
|
||||
// Paid like nearby-places, so gated the same way, first.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -200,7 +219,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
|
||||
// contract the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
|
||||
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
|
||||
*/
|
||||
export function aisLiveProxy() {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/ais-live', async (req, res) => {
|
||||
middlewares.use('/api/vessels', async (req, res) => {
|
||||
try {
|
||||
res.setHeader('X-Feed-Source', 'AISStream');
|
||||
ensureAisStreamConnection();
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
|
||||
// Track sub-route MUST be handled before the rows snapshot — this
|
||||
// mount prefix-matches every subpath, so without this branch
|
||||
// /api/ais-live/track would be silently answered with vessel rows.
|
||||
// /api/vessels/track would be silently answered with vessel rows.
|
||||
if (
|
||||
incoming.pathname === '/track' ||
|
||||
incoming.pathname.startsWith('/track/')
|
||||
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the watchdog once. Called on every /api/ais-live request and on the
|
||||
* Drive the watchdog once. Called on every /api/vessels request and on the
|
||||
* background interval, so recovery does not depend on browser traffic.
|
||||
*/
|
||||
function ensureAisStreamConnection() {
|
||||
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
|
||||
keyFingerprint: aisKeyFingerprint(),
|
||||
});
|
||||
}
|
||||
/** Status metadata for /api/ais-live, safe to call before the first connect. */
|
||||
/** Status metadata for /api/vessels, safe to call before the first connect. */
|
||||
function aisStreamStatusSnapshot() {
|
||||
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
|
||||
if (snapshot) return snapshot;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { resolveAllowedHosts } from '../../build/allowedHosts.js';
|
||||
import { createBrowserViteConfig } from '../../build/vite.js';
|
||||
import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
@@ -24,6 +25,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
|
||||
command,
|
||||
});
|
||||
});
|
||||
+26
-14
@@ -249,9 +249,11 @@ export function installViews({
|
||||
// A framing page talks to the app across frames; an inline panel shares
|
||||
// the page with it and talks through the page's own window.
|
||||
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
|
||||
const post = (message) => {
|
||||
// Answers go back only to the origin that asked. An inline panel posts to
|
||||
// its own window, and a sandboxed framing page has no origin to name.
|
||||
const post = (message, origin = '*') => {
|
||||
if (peer && (peer !== windowRef || isEmbeddedInline()))
|
||||
peer.postMessage(message, '*');
|
||||
peer.postMessage(message, origin);
|
||||
};
|
||||
// Views apply one at a time, in the order they arrive.
|
||||
let queue = ready;
|
||||
@@ -259,27 +261,37 @@ export function installViews({
|
||||
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
|
||||
return;
|
||||
const { id = null } = event.data;
|
||||
const replyOrigin =
|
||||
isEmbeddedInline() || !event.origin || event.origin === 'null'
|
||||
? '*'
|
||||
: event.origin;
|
||||
let view;
|
||||
try {
|
||||
view = createView(event.data.view);
|
||||
} catch (error) {
|
||||
post({
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
post({
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
id,
|
||||
ok: steps.every((step) => step.ok),
|
||||
steps,
|
||||
},
|
||||
replyOrigin,
|
||||
);
|
||||
});
|
||||
};
|
||||
windowRef.addEventListener('message', onMessage);
|
||||
|
||||
+16
-3
@@ -102,7 +102,13 @@ test('a followed entity is retried until its layer has it', async () => {
|
||||
|
||||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||||
const posted = [];
|
||||
const parent = { postMessage: (message) => posted.push(message) };
|
||||
const targets = [];
|
||||
const parent = {
|
||||
postMessage: (message, target) => {
|
||||
posted.push(message);
|
||||
targets.push(target);
|
||||
},
|
||||
};
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = parent;
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
@@ -126,9 +132,9 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(shell.clean, true);
|
||||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||||
const send = (source, data) => {
|
||||
const send = (source, data, origin = 'https://host.example') => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data });
|
||||
Object.assign(event, { source, data, origin });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
|
||||
@@ -154,6 +160,13 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
assert.deepEqual(targets, [
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ export function panelRuntime(config) {
|
||||
try {
|
||||
const result = await request('tools/call', {
|
||||
name: config.toolName,
|
||||
arguments: args,
|
||||
// The server refuses the panel's requests without its key.
|
||||
arguments: { ...args, key: config.panelKey },
|
||||
});
|
||||
if (result?.isError || !result?.structuredContent)
|
||||
throw new Error(result?.content?.[0]?.text || 'The request failed');
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
|
||||
export async function createApplicationScene({
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
cesiumToken,
|
||||
credits,
|
||||
MapController = MapStackController,
|
||||
@@ -59,11 +60,12 @@ export async function createApplicationScene({
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
|
||||
@@ -86,23 +86,47 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
|
||||
assert.doesNotMatch(css, /cockpit-compass-label/);
|
||||
});
|
||||
|
||||
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
|
||||
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
|
||||
test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
assert.match(
|
||||
cycleVisionMode.toString(),
|
||||
/const modes = COCKPIT_VISION_MODES;/,
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: 'NORMAL',\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*anime: 'ANIME',\s*noir: 'NOIR',\s*snow: 'SNOW',?\s*\};/,
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
|
||||
'Cockpit must enter on the mode matching the active map preset',
|
||||
);
|
||||
assert.match(
|
||||
readRadioSource(
|
||||
new URL('./ui/cockpitCoordinator.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
/getInitialVisionMode:\s*\(\)\s*=>\s*cockpitVisionModeForStyle\(this\.activeStyle\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
|
||||
/const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);[\s\S]*?this\._updateSliderPanel\(null,[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'Normal must clear the inherited map shader while the other map presets remain temporary Cockpit overrides',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_syncCockpitInheritedStyle\(\)[\s\S]*?name === this\.activeStyle \? 1 : 0[\s\S]*?this\.transitions\.delete\(name\)[\s\S]*?setVisionMode\(this\.cockpitView\.visionMode\)/,
|
||||
'changing the map preset in Cockpit must refresh both the inherited label and restore baseline',
|
||||
'changing the map preset in Cockpit must refresh the exit restore baseline without replacing Normal',
|
||||
);
|
||||
assert.match(ui, /setStyle\([\s\S]*?this\._syncCockpitInheritedStyle\(\);/);
|
||||
});
|
||||
|
||||
@@ -3,21 +3,40 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
crt: 'retro',
|
||||
nvg: 'surveillance',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
normal: 'optical',
|
||||
retro: 'crt',
|
||||
surveillance: 'nvg',
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
export function normalizeCockpitVisionMode(mode) {
|
||||
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
|
||||
}
|
||||
|
||||
/** Start Cockpit on the equivalent supported map preset. */
|
||||
export function cockpitVisionModeForStyle(style) {
|
||||
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
|
||||
}
|
||||
|
||||
/** Settle pending map-style crossfades and return their intended final intensities. */
|
||||
export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
const baseline = {};
|
||||
@@ -34,12 +53,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
|
||||
* Apply Cockpit-only stage intensities without changing any shader parameters.
|
||||
* Returns the temporary style whose parameters should be shown, or null.
|
||||
*/
|
||||
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
|
||||
export function applyCockpitVisionStageIntensities(stages, mode) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
if (next === 'optical') {
|
||||
for (const [name, intensity] of Object.entries(restore)) {
|
||||
if (stages[name]) stages[name].uniforms.intensity = intensity;
|
||||
}
|
||||
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
applyCockpitVisionStageIntensities,
|
||||
captureCockpitVisionBaseline,
|
||||
cockpitVisionModeForStyle,
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
} from './cockpitVisionPolicy.js';
|
||||
@@ -14,12 +15,31 @@ const createStages = () => ({
|
||||
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
|
||||
});
|
||||
|
||||
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
|
||||
test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
assert.deepEqual(COCKPIT_VISION_MODES, [
|
||||
'optical',
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
|
||||
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
|
||||
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
|
||||
const stages = createStages();
|
||||
const transitions = new Map([
|
||||
@@ -29,49 +49,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
|
||||
const restore = captureCockpitVisionBaseline(stages, transitions);
|
||||
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
|
||||
assert.equal(transitions.size, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'nvg');
|
||||
assert.equal(stages.surveillance.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
});
|
||||
|
||||
test('returning from a temporary preset restores the exact inherited intensities', () => {
|
||||
test('Normal clears every temporary and inherited visual stage', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
|
||||
restore,
|
||||
Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
),
|
||||
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
|
||||
);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
|
||||
applyCockpitVisionStageIntensities(stages, 'thermal');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
|
||||
assert.equal(stages.retro.uniforms.intensity, 1);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
|
||||
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
|
||||
const stages = createStages();
|
||||
const restore = Object.fromEntries(
|
||||
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
|
||||
Object.entries(stages).map(([name, stage]) => [
|
||||
name,
|
||||
stage.uniforms.intensity,
|
||||
]),
|
||||
);
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
|
||||
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
|
||||
assert.equal(stages.noir.uniforms.intensity, 1);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical', restore);
|
||||
assert.equal(stages.noir.uniforms.intensity, 0.72);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0.18);
|
||||
applyCockpitVisionStageIntensities(stages, 'optical');
|
||||
assert.equal(stages.noir.uniforms.intensity, 0);
|
||||
assert.equal(stages.retro.uniforms.intensity, 0);
|
||||
assert.deepEqual(restore, {
|
||||
noir: 0.72,
|
||||
retro: 0.18,
|
||||
surveillance: 0,
|
||||
thermal: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import {
|
||||
makeCostRateLimiter,
|
||||
resolvePerMinuteCap,
|
||||
} from '../server/providers/common/rate-limit.js';
|
||||
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
|
||||
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
|
||||
import {
|
||||
googlePlacesContextProxy,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
} from '../server/providers/places/google.js';
|
||||
|
||||
// Both limiters are cached at module scope on first use, so the env value that
|
||||
// matters is the one in place when a route is first exercised — restoring the
|
||||
// variable afterwards does not rebuild the limiter. Each route is therefore
|
||||
// driven by exactly one test here; a second test wanting a different cap would
|
||||
// need its own process, not just a different env value.
|
||||
|
||||
/** Set an env var for one test and put the old value back afterwards. */
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
restart: async () => {},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function request(
|
||||
handler,
|
||||
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from([]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({ status: this.statusCode, headers, body: String(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unset throttle resolves to the documented default, not to unlimited', () => {
|
||||
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
|
||||
// anyone who could reach it mint tokens on the operator's key without bound.
|
||||
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
|
||||
assert.equal(resolvePerMinuteCap('', 30), 30);
|
||||
assert.equal(resolvePerMinuteCap(' ', 30), 30);
|
||||
assert.ok(
|
||||
typeof makeCostRateLimiter(undefined, 30) === 'function',
|
||||
'an unconfigured cost endpoint must still be throttled',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit 0 is the documented way to run unthrottled', () => {
|
||||
assert.equal(resolvePerMinuteCap('0', 30), 0);
|
||||
assert.equal(
|
||||
makeCostRateLimiter('0', 30),
|
||||
null,
|
||||
'operators who mean "no throttle" must still be able to say so',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured value wins, and a fractional one floors', () => {
|
||||
assert.equal(resolvePerMinuteCap('45', 30), 45);
|
||||
assert.equal(resolvePerMinuteCap(45, 30), 45);
|
||||
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
|
||||
});
|
||||
|
||||
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
|
||||
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
|
||||
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
|
||||
assert.equal(
|
||||
typeof makeCostRateLimiter(fraction, 30),
|
||||
'function',
|
||||
`${fraction} must still throttle`,
|
||||
);
|
||||
}
|
||||
// Only an explicit zero turns the limiter off.
|
||||
assert.equal(makeCostRateLimiter('0', 30), null);
|
||||
assert.equal(makeCostRateLimiter('0.0', 30), null);
|
||||
});
|
||||
|
||||
test('an unreadable value keeps the guard instead of removing it', () => {
|
||||
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
|
||||
// Reading either as "unlimited" would disarm the spend guard on a typo —
|
||||
// the opposite of what the operator was reaching for when they typed it.
|
||||
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
|
||||
assert.equal(
|
||||
resolvePerMinuteCap(typo, 30),
|
||||
30,
|
||||
`${typo} must fall back to the default, not to unlimited`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default cap clears the app’s own demand with room to spare', () => {
|
||||
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
|
||||
// tab, plus one token mint per voice session. The cap has to sit well above
|
||||
// that or it would throttle the app rather than an abuser.
|
||||
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
|
||||
assert.ok(
|
||||
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
|
||||
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
|
||||
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
|
||||
statuses.push((await request(handler)).status);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
const overLimit = await request(handler);
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
let upstreamCalls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ output_text: 'five word hud summary here' });
|
||||
});
|
||||
|
||||
// Its own client address: the limiter is cached at module scope and shared
|
||||
// across the OpenAI routes, so the realtime case above has already spent
|
||||
// 127.0.0.1's budget inside this process.
|
||||
const from = '10.9.9.9';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
|
||||
statuses.push(
|
||||
(await request(handleHudSummary, { remoteAddress: from })).status,
|
||||
);
|
||||
assert.deepEqual(
|
||||
statuses,
|
||||
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
|
||||
'the default must not throttle ordinary HUD use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
|
||||
assert.equal(
|
||||
overLimit.body.includes('fixture-upstream-secret'),
|
||||
false,
|
||||
'a throttled reply must not leak the server-held key',
|
||||
);
|
||||
assert.ok(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
|
||||
);
|
||||
|
||||
// The keyless HUD answer is deliberately NOT throttled: it is composed
|
||||
// locally, spends nothing upstream, and capping it would only take the free
|
||||
// HUD away from an unconfigured install. Same exhausted client, no key.
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
const free = await request(handleHudSummary, { remoteAddress: from });
|
||||
assert.equal(free.status, 200, 'the free path answers past a spent budget');
|
||||
assert.equal(JSON.parse(free.body).configured, false);
|
||||
assert.equal(
|
||||
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
|
||||
true,
|
||||
'the free path reaches no provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
|
||||
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
|
||||
let upstreamCalls = 0;
|
||||
const handler = install(
|
||||
googlePlacesContextProxy({
|
||||
resolveApiKey: () => 'fixture-google-server-key',
|
||||
fetchImpl: async () => {
|
||||
upstreamCalls += 1;
|
||||
return Response.json({ places: [] });
|
||||
},
|
||||
}),
|
||||
).get('/api/google/nearby-places');
|
||||
|
||||
const url = '/?lat=37.77493&lon=-122.41942';
|
||||
const statuses = [];
|
||||
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
|
||||
statuses.push((await request(handler, { url, method: 'GET' })).status);
|
||||
}
|
||||
assert.ok(
|
||||
statuses.every((s) => s === 200),
|
||||
'the default must not throttle ordinary use',
|
||||
);
|
||||
|
||||
const overLimit = await request(handler, { url, method: 'GET' });
|
||||
assert.equal(overLimit.status, 429);
|
||||
assert.equal(overLimit.headers['retry-after'], '5');
|
||||
// Every error response on this route carries places[]; the client reads it
|
||||
// unconditionally, so a throttled reply must not be the one shape that omits it.
|
||||
assert.deepEqual(JSON.parse(overLimit.body).places, []);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
'a throttled request must be refused before it reaches Google, or it still costs money',
|
||||
);
|
||||
});
|
||||
|
||||
test('the code defaults match the caps the Pinokio build ships', () => {
|
||||
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
|
||||
// this repo suggested elsewhere had already drifted away from them. Now that
|
||||
// they are the server's defaults, the packaged app and an unconfigured
|
||||
// checkout must agree, or a Pinokio user's throttle silently stops matching
|
||||
// the one everybody else gets. If you deliberately want them to differ, this
|
||||
// assertion is the place to say so.
|
||||
const shipped = readPinokioEnvironment(
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'pinokio',
|
||||
'_ENVIRONMENT',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
assert.equal(
|
||||
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
});
|
||||
@@ -58,30 +58,18 @@ export function normalizeAdminName(value) {
|
||||
const PACKS = {
|
||||
countries: {
|
||||
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/countries.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
admin1: {
|
||||
url: new URL(
|
||||
'./local_data/natural_earth/states_provinces.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/states_provinces.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
counties: {
|
||||
url: new URL(
|
||||
'./local_data/us_census_counties/counties.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/us_census_counties/counties.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -297,7 +285,7 @@ function addKey(index, key, entry, tier) {
|
||||
|
||||
const loadAdmin1 = createRetryableLoader(async () => {
|
||||
packLoads.admin1 += 1;
|
||||
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
|
||||
const pack = await loadBundledJson(PACKS.admin1.url);
|
||||
const decimals = pack.meta?.decimals ?? 3;
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
@@ -323,10 +311,7 @@ const loadAdmin1 = createRetryableLoader(async () => {
|
||||
|
||||
const loadCounties = createRetryableLoader(async () => {
|
||||
packLoads.counties += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.counties.url,
|
||||
PACKS.counties.importJson,
|
||||
);
|
||||
const pack = await loadBundledJson(PACKS.counties.url);
|
||||
const decimals = pack.meta?.decimals ?? 4;
|
||||
const byFull = new Map();
|
||||
const byName = new Map();
|
||||
@@ -348,10 +333,7 @@ const loadCounties = createRetryableLoader(async () => {
|
||||
|
||||
const loadCountries = createRetryableLoader(async () => {
|
||||
packLoads.countries += 1;
|
||||
const pack = await loadBundledJson(
|
||||
PACKS.countries.url,
|
||||
PACKS.countries.importJson,
|
||||
);
|
||||
const pack = await loadBundledJson(PACKS.countries.url);
|
||||
const index = new Map();
|
||||
for (const feature of pack.features || []) {
|
||||
const entry = {
|
||||
|
||||
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
navStatus: null, // /api/vessels does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
|
||||
|
||||
import { createAisStreamSource } from '../sources/live/standalone.js';
|
||||
import { createVesselSource } from '../sources/live/standalone.js';
|
||||
|
||||
const aisLiveVesselsLayer = createApplicationVessels({
|
||||
source: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
source: createVesselSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
|
||||
// Resolved against the document's address, which a panel host may serve
|
||||
// from its own scheme.
|
||||
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
|
||||
|
||||
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createAisStreamSource } = await import('../sources/live/standalone.js');
|
||||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createAisStreamSource());
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
/** Status metadata for /api/vessels. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
|
||||
+22
-8
@@ -4,24 +4,38 @@
|
||||
* The browser fetches the file from `url` — a `new URL('./….json',
|
||||
* import.meta.url)` written in the calling module, which Vite serves as-is in
|
||||
* dev and emits as an asset in the production build. Under Node (node:test)
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
|
||||
* JSON-attributed import runs instead.
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
|
||||
* from disk instead.
|
||||
*
|
||||
* The attributed import cannot serve the browser: the Vite dev server answers
|
||||
* it with a JavaScript module, which the browser rejects for a JSON import.
|
||||
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
|
||||
* serve the browser (the Vite dev server answers it with a JavaScript module,
|
||||
* which the browser rejects), and a literal `import('./….json')` makes the
|
||||
* production build emit a JavaScript copy of every pack that the browser
|
||||
* never loads.
|
||||
*
|
||||
* @module data/bundledJson
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
|
||||
* @param {() => Promise<{default: any}>} importJson - The caller's literal
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson, { signal } = {}) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
export async function loadBundledJson(url, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (url.protocol === 'file:') {
|
||||
// Node's built-in, reached without an import, so the browser build never
|
||||
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
|
||||
// read is synchronous so the result settles as promptly as the cached
|
||||
// module import it replaces; callers already memoize each pack.
|
||||
const getBuiltinModule = globalThis.process?.getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== 'function')
|
||||
throw new Error(
|
||||
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
|
||||
);
|
||||
const { readFileSync } = getBuiltinModule('node:fs');
|
||||
return JSON.parse(readFileSync(url, 'utf8'));
|
||||
}
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
|
||||
@@ -2,13 +2,14 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
|
||||
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
|
||||
const importMarine = () =>
|
||||
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
|
||||
const MARINE = new URL(
|
||||
'./local_data/natural_earth/marine.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
|
||||
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch');
|
||||
const pack = await loadBundledJson(MARINE, importMarine);
|
||||
const pack = await loadBundledJson(MARINE);
|
||||
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
@@ -21,16 +22,62 @@ test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
|
||||
const importJson = () => assert.fail('the browser path must not import JSON');
|
||||
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
|
||||
const url = new URL(
|
||||
'http://localhost/src/data/local_data/natural_earth/marine.json',
|
||||
);
|
||||
assert.deepEqual(await loadBundledJson(url), { features: [] });
|
||||
assert.deepEqual(requests, [url.href]);
|
||||
});
|
||||
|
||||
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 404 }),
|
||||
);
|
||||
await assert.rejects(
|
||||
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
|
||||
loadBundledJson(new URL('http://localhost/missing.json')),
|
||||
/HTTP 404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('bundled JSON: an aborted load rejects before reading', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('bundled JSON: no data pack is imported as a module', async () => {
|
||||
// A literal JSON import in app code makes the production build emit a
|
||||
// JavaScript copy of the pack beside the fetched JSON, which the browser
|
||||
// never loads. Tests are not bundled, so they may still import JSON.
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
[
|
||||
'grep',
|
||||
'-nE',
|
||||
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
|
||||
'--',
|
||||
'src',
|
||||
':!*.test.mjs',
|
||||
],
|
||||
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
||||
);
|
||||
// git grep exits 1 when nothing matches.
|
||||
assert.equal(result.status, 1, result.stdout);
|
||||
});
|
||||
|
||||
test('bundled JSON: an older Node says which version is needed', async (t) => {
|
||||
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
|
||||
const original = process.getBuiltinModule;
|
||||
t.after(() => {
|
||||
process.getBuiltinModule = original;
|
||||
});
|
||||
process.getBuiltinModule = undefined;
|
||||
await assert.rejects(loadBundledJson(MARINE), {
|
||||
message: /needs Node 24\.14 or newer to read marine\.json/,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -240,6 +240,13 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationFlights } from '../app/layers/flights.js';
|
||||
|
||||
import { createOpenSkySource } from '../sources/live/standalone.js';
|
||||
import { createFlightSource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const flightsLayer = createApplicationFlights({
|
||||
surface: defaultSurface,
|
||||
source: createOpenSkySource(),
|
||||
source: createFlightSource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
|
||||
|
||||
@@ -203,7 +203,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
let openskyPoll = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).startsWith('/api/opensky')) {
|
||||
if (!String(url).startsWith('/api/flights')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
|
||||
}
|
||||
const states = openskyPoll++ === 0
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
* @param {object} services Caller-owned context, overlay and render operations.
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(services) {
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
const datacenters = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-datacenters',
|
||||
|
||||
@@ -476,7 +476,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
// the dependencies reach it by different routes:
|
||||
//
|
||||
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
|
||||
// as soon as the first /api/ais-live poll answers, so the manager's
|
||||
// as soon as the first /api/vessels poll answers, so the manager's
|
||||
// lifecycle settles to `enabled` — but until the server-side socket delivers
|
||||
// a position, firstConnectPhase stays 'loading' and the module reports busy,
|
||||
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
||||
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
import { createMilitarySource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const militaryFlightsLayer = createApplicationMilitary({
|
||||
surface: defaultSurface,
|
||||
source: createAdsbLolSource(),
|
||||
source: createMilitarySource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
|
||||
|
||||
+327
-102
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
|
||||
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
|
||||
'source-unavailable',
|
||||
);
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
|
||||
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
ids: ['different'],
|
||||
});
|
||||
const missing =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(missing.status, 'missing');
|
||||
assert.equal(missing.reason, 'target-absent-from-snapshot');
|
||||
});
|
||||
@@ -44,9 +48,9 @@ const FULL_INFO = {
|
||||
operator: 'United States Air Force',
|
||||
};
|
||||
|
||||
test('military stats identify adsb.lol as the primary feed, not a fallback', () => {
|
||||
test('military stats name the feed until the server names its provider, not a fallback', () => {
|
||||
const stats = militaryFlightsLayer.getStats();
|
||||
assert.equal(stats.source, 'adsb.lol');
|
||||
assert.equal(stats.source, 'Military aircraft');
|
||||
assert.equal(stats.fallback, false);
|
||||
});
|
||||
|
||||
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
|
||||
});
|
||||
|
||||
test('military analyst record: no callsign falls back to registration, then icao24', () => {
|
||||
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
|
||||
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
|
||||
'05-8152',
|
||||
);
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
|
||||
'ae01ce',
|
||||
);
|
||||
});
|
||||
|
||||
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
|
||||
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
|
||||
assert.equal(r.onGround, false);
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
if (typeof value === 'number')
|
||||
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,12 +114,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
|
||||
test('military first update forwards caller cancellation into the feed request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let observedSignal = null;
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
|
||||
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
|
||||
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
|
||||
});
|
||||
try {
|
||||
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
await militaryFlightsLayer.update({
|
||||
camera: { positionCartographic: null },
|
||||
scene: {},
|
||||
});
|
||||
const resolution =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(resolution.status, 'source-unavailable');
|
||||
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
|
||||
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
|
||||
assert.match(
|
||||
militaryFlightsLayer.getStats().error,
|
||||
/Malformed adsb\.lol aircraft rows/,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getAnalystRecords().length,
|
||||
1,
|
||||
'warm aircraft data is preserved',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -188,21 +215,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ac: poll++ === 0 ? [{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
}] : [],
|
||||
ac:
|
||||
poll++ === 0
|
||||
? [
|
||||
{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae01ce',
|
||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
history,
|
||||
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
});
|
||||
let cache = 'MISS';
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({
|
||||
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000,
|
||||
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }],
|
||||
}, { headers: {
|
||||
'X-ADS-B-Cache': cache,
|
||||
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }),
|
||||
} }));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
lon: -97,
|
||||
lat: 31,
|
||||
alt_baro: 28000,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 1,
|
||||
seen_pos: 2,
|
||||
flight: 'RCH451',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Feed-Cache': cache,
|
||||
...(cache === 'MISS'
|
||||
? {}
|
||||
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1);
|
||||
assert.equal(history[0].epochMs, receivedAt - 2000);
|
||||
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
|
||||
for (const [status, age] of [
|
||||
['HIT', 5000],
|
||||
['STALE', 45000],
|
||||
]) {
|
||||
cache = status;
|
||||
now = receivedAt + age;
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
|
||||
assert.equal(
|
||||
history.length,
|
||||
1,
|
||||
'replayed positions cannot create a new fix',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
|
||||
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
|
||||
assert.equal(militaryFlightsLayer.getStats().error, null);
|
||||
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() { cancelledFlights += 1; },
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() { appliedFrames += 1; },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.window = new EventTarget();
|
||||
const selectionEvents = [];
|
||||
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
});
|
||||
globalThis.window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
(event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
_setTrackedOverlayHostForTest(host);
|
||||
try {
|
||||
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
}],
|
||||
history: [
|
||||
{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
...FULL_INFO,
|
||||
type: 'C17',
|
||||
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'RCH451',
|
||||
details: [
|
||||
'C17 · 05-8152',
|
||||
'United States Air Force · 28000 ft · 450 kt',
|
||||
],
|
||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
||||
accent: '#ffd166',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'user',
|
||||
'same-target selection upgrades durable authority',
|
||||
);
|
||||
assert.equal(entity.gevSelectionOrigin, 'user');
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById('different-flight'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'voice',
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
|
||||
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
|
||||
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
const entry = publication.entries[0];
|
||||
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
display,
|
||||
'host must reuse the exact cached display Cartesian',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late001' },
|
||||
{ origin: 'share-restore' },
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late001',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
icao24,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.stopTracking();
|
||||
militaryFlightsLayer.setParams(
|
||||
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
|
||||
militaryFlightsLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late002',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late003' },
|
||||
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late003',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late004' },
|
||||
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late004',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late004',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late005' },
|
||||
{ origin: 'local-restore' },
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
'late005',
|
||||
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late005',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late005',
|
||||
);
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
|
||||
|
||||
// The identity analyst_query hands back for this contact.
|
||||
const analystId = mapAnalystRecord(icao24, {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
}).id;
|
||||
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
|
||||
assert.equal(
|
||||
analystId,
|
||||
'6606',
|
||||
'the analyst names a callsign-less contact by its tail',
|
||||
);
|
||||
|
||||
const found = militaryFlightsLayer.findByQuery(analystId);
|
||||
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
|
||||
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
|
||||
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
|
||||
assert.equal(
|
||||
found?.icao24,
|
||||
icao24,
|
||||
'and the tracker must resolve that same identity',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery('660')?.icao24,
|
||||
icao24,
|
||||
'prefix match too',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery(icao24)?.icao24,
|
||||
icao24,
|
||||
'hex still wins outright',
|
||||
);
|
||||
});
|
||||
|
||||
test('the analyst record carries the hex key the tracker keys on', () => {
|
||||
const record = mapAnalystRecord('ae7f01', {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
});
|
||||
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
|
||||
assert.equal(
|
||||
record.icao24,
|
||||
'ae7f01',
|
||||
'a resolvable key must ride along with the display label',
|
||||
);
|
||||
});
|
||||
|
||||
test('a registration never outranks another contact’s exact callsign', () => {
|
||||
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
|
||||
track: 90, klass: 'widebody', onGround: false,
|
||||
type: 'C17',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 25000,
|
||||
speedMps: 180,
|
||||
track: 90,
|
||||
klass: 'widebody',
|
||||
onGround: false,
|
||||
};
|
||||
// Registered FIRST, so insertion order favours it.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0aa1',
|
||||
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'ZULU777',
|
||||
rawLat: 31.0,
|
||||
rawLon: -97.0,
|
||||
},
|
||||
billboard: billboardAt(-97.0, 31.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0bb2',
|
||||
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: 'ZULU777',
|
||||
registration: '05-9999',
|
||||
rawLat: 31.2,
|
||||
rawLon: -97.2,
|
||||
},
|
||||
billboard: billboardAt(-97.2, 31.2),
|
||||
});
|
||||
|
||||
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
|
||||
track: 45, klass: 'turboprop', onGround: false,
|
||||
type: 'C130',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 21000,
|
||||
speedMps: 150,
|
||||
track: 45,
|
||||
klass: 'turboprop',
|
||||
onGround: false,
|
||||
};
|
||||
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
|
||||
// highest-hex first so insertion order would pick the wrong one.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef002',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-222',
|
||||
rawLat: 32.0,
|
||||
rawLon: -98.0,
|
||||
},
|
||||
billboard: billboardAt(-98.0, 32.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef001',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-111',
|
||||
rawLat: 32.1,
|
||||
rawLon: -98.1,
|
||||
},
|
||||
billboard: billboardAt(-98.1, 32.1),
|
||||
});
|
||||
|
||||
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
|
||||
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
|
||||
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
|
||||
assert.equal(second, first, 'and the same query resolves the same way every time');
|
||||
assert.equal(
|
||||
first,
|
||||
'aef001',
|
||||
'the stable key (lowest hex) wins, not the feed order',
|
||||
);
|
||||
assert.equal(
|
||||
second,
|
||||
first,
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
@@ -14,10 +14,6 @@ export function createMilitaryNamesLoader({
|
||||
loadPack = (signal) =>
|
||||
loadBundledJson(
|
||||
new URL('./local_data/osm_military_names/names.json', import.meta.url),
|
||||
() =>
|
||||
import('./local_data/osm_military_names/names.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
import { createMilitarySource } from '../sources/live/standalone.js';
|
||||
|
||||
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
|
||||
const registry = createMilitaryRegistry({ source: createMilitarySource() });
|
||||
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
|
||||
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
|
||||
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
|
||||
|
||||
@@ -124,17 +124,9 @@ let _entries = null;
|
||||
const PACKS = {
|
||||
regions: {
|
||||
url: new URL('./local_data/natural_earth/regions.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/regions.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
marine: {
|
||||
url: new URL('./local_data/natural_earth/marine.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/marine.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -178,8 +170,8 @@ function buildEntries(pack, kind) {
|
||||
*/
|
||||
const loadIndex = createRetryableLoader(async () => {
|
||||
const [regions, marine] = await Promise.all([
|
||||
loadBundledJson(PACKS.regions.url, PACKS.regions.importJson),
|
||||
loadBundledJson(PACKS.marine.url, PACKS.marine.importJson),
|
||||
loadBundledJson(PACKS.regions.url),
|
||||
loadBundledJson(PACKS.marine.url),
|
||||
]);
|
||||
_entries = [
|
||||
...buildEntries(regions, 'natural'),
|
||||
|
||||
@@ -22,10 +22,6 @@ const CITY_FILES = [
|
||||
'./local_data/neighborhoods/san-francisco.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/neighborhoods/san-francisco.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -112,7 +108,7 @@ function cityLoader(city) {
|
||||
let loader = _cityLoaders.get(city.id);
|
||||
if (!loader) {
|
||||
loader = createRetryableLoader(async () => {
|
||||
const fc = await loadBundledJson(city.url, city.importJson);
|
||||
const fc = await loadBundledJson(city.url);
|
||||
return Array.isArray(fc.features) ? fc.features : [];
|
||||
});
|
||||
_cityLoaders.set(city.id, loader);
|
||||
|
||||
@@ -189,7 +189,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
const realFetch = globalThis.fetch;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).startsWith('/api/opensky')) {
|
||||
if (!String(url).startsWith('/api/flights')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
|
||||
}
|
||||
return {
|
||||
@@ -309,7 +309,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
|
||||
});
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => (String(url).startsWith('/api/opensky')
|
||||
globalThis.fetch = async (url) => (String(url).startsWith('/api/flights')
|
||||
? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
|
||||
: { ok: true, status: 200, json: async () => ({ ac: [] }) });
|
||||
try {
|
||||
|
||||
+25
-23
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Key setup ("POWER UP") — the pure core.
|
||||
*
|
||||
* One registry, three pure functions, zero dependencies. The dev server's
|
||||
* One registry and pure functions; its only import is the proxy-header check
|
||||
* shared with the other local gates (localRequestGate.mjs). The dev server's
|
||||
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
|
||||
* are both thin shells over this module, so what a key is called, what it
|
||||
* unlocks, and how a .env line is written each live in exactly one place.
|
||||
@@ -11,6 +12,8 @@
|
||||
* behavior below unit-testable.
|
||||
*/
|
||||
|
||||
import { hasProxySignals } from './localRequestGate.mjs';
|
||||
|
||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||
export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||
|
||||
@@ -77,7 +80,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
unlocks: 'Real live traffic (keyless runs a simulation)',
|
||||
getUrl: 'https://developer.tomtom.com',
|
||||
getUrl: 'https://my.tomtom.com/keys',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
@@ -184,6 +187,24 @@ export function parseWindowsUserSid(stdout) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the launcher has sharing on. Every sharing signal the launcher
|
||||
* recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot
|
||||
* drift apart. One DELIBERATE divergence: preflight is a boot check that
|
||||
* treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed before Start),
|
||||
* but here an empty/unset value is the NORMAL git-clone and Pinokio state, so
|
||||
* a bare/sentinel value is not sharing; only a real tunnel var is.
|
||||
*/
|
||||
export function isSharingEnabled(env = {}) {
|
||||
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
|
||||
return (
|
||||
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
|
||||
/^(1|true)$/i.test(String(env[name] || '').trim()),
|
||||
) ||
|
||||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admission gate for the Provider Settings endpoints — pure, exported so
|
||||
* every refusal below is pinned by a unit assertion rather than a review note.
|
||||
@@ -219,20 +240,7 @@ export function admitKeySetupRequest({
|
||||
// on this machine, whatever its socket says. Refuse them outright as defense
|
||||
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
|
||||
// only appear when someone has deliberately fronted the dev server.
|
||||
const PROXY_SIGNALS = [
|
||||
'forwarded',
|
||||
'via',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-port',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'cf-connecting-ip',
|
||||
'cf-ray',
|
||||
];
|
||||
if (
|
||||
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
|
||||
) {
|
||||
if (hasProxySignals(proxyHeaders)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
@@ -249,13 +257,7 @@ export function admitKeySetupRequest({
|
||||
// tunnel var is. This is defense in depth regardless: the loopback+Host checks
|
||||
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
|
||||
// refuses to boot at all when sharing is genuinely on.
|
||||
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
|
||||
const sharingEnabled =
|
||||
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
|
||||
/^(1|true)$/i.test(String(env[name] || '').trim()),
|
||||
) ||
|
||||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
|
||||
if (sharingEnabled) {
|
||||
if (isSharingEnabled(env)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// setInstallationStatus is only ever called with
|
||||
// loading/zoom-in/ready/stale/empty/unavailable.
|
||||
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
|
||||
// update() both resolve as soon as the first /api/ais-live poll answers,
|
||||
// update() both resolve as soon as the first /api/vessels poll answers,
|
||||
// so the lifecycle settles to `enabled` — but until the server-side socket
|
||||
// delivers a position, firstConnectPhase is 'loading' and getStats()
|
||||
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
|
||||
|
||||
@@ -4,22 +4,47 @@ import { readFileSync } from 'node:fs';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
|
||||
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
|
||||
import {
|
||||
createMilitaryNamesLoader,
|
||||
loadMilitaryNames,
|
||||
} from '../../data/militaryNames.js';
|
||||
import { loadBundledJson } from '../../data/bundledJson.js';
|
||||
|
||||
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
|
||||
const wide = { south: 20, west: -130, north: 60, east: -60 };
|
||||
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
|
||||
function sourceWith(job) { return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
}); }
|
||||
const tile = decodeOpenFreeMapMilitaryTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
12,
|
||||
935,
|
||||
1685,
|
||||
);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function sourceWith(job) {
|
||||
return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
});
|
||||
}
|
||||
|
||||
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
|
||||
const job = deferred(), source = sourceWith(job), abort = new AbortController();
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
abort = new AbortController();
|
||||
const waiting = source.getMappedSites(wide, { signal: abort.signal });
|
||||
const rejection = assert.rejects(waiting, { name: 'AbortError' });
|
||||
const payload = await source.getMappedSites(box);
|
||||
@@ -32,48 +57,81 @@ test('close polygons publish immediately while a wide consumer can cancel its in
|
||||
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
|
||||
});
|
||||
|
||||
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(), source = sourceWith(job), publications = [];
|
||||
let currentBox = box;
|
||||
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
|
||||
const ingestion = createIngestion({ state, source,
|
||||
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
|
||||
parts: {
|
||||
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
|
||||
},
|
||||
for (const stale of [false, true])
|
||||
test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
publications = [];
|
||||
let currentBox = box;
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {},
|
||||
records: [],
|
||||
googleSearchRequested: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
parts: {
|
||||
viewport: {
|
||||
loadArea: () => ({ box: currentBox, coverage: {} }),
|
||||
clearUnavailableRetry() {},
|
||||
scheduleUnavailableRetry() {},
|
||||
},
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: {
|
||||
renderRecords: () => publications.push(state.records),
|
||||
warmInstallationFloors() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) {
|
||||
currentBox = wide;
|
||||
state.enabled = false;
|
||||
state.abort.abort();
|
||||
}
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
|
||||
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0, signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++; signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
for (const stage of ['response', 'body'])
|
||||
test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0,
|
||||
signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++;
|
||||
signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
});
|
||||
const load = createMilitaryNamesLoader({
|
||||
timeoutMs: 50,
|
||||
cooldownMs: 100,
|
||||
loadPack: (signal) =>
|
||||
loadBundledJson(new URL('https://example.test/names.json'), { signal }),
|
||||
});
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush();
|
||||
t.mock.timers.tick(51);
|
||||
await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
|
||||
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush(); t.mock.timers.tick(51); await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
|
||||
// Commercial users must remove it or obtain a TeleGeography license.
|
||||
// See DATA_SOURCES.md and the bundled dataset's source.json.
|
||||
const cableUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const landingPointUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
// Resolved when read, so importing this module never needs a module URL.
|
||||
function cableUrl() {
|
||||
return new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
}
|
||||
function landingPointUrl() {
|
||||
return new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
}
|
||||
|
||||
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
|
||||
export function createBundledCableSource({
|
||||
@@ -33,8 +38,8 @@ export function createBundledCableSource({
|
||||
label: 'TeleGeography',
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl, signal),
|
||||
read(landingPointUrl, signal),
|
||||
read(cableUrl(), signal),
|
||||
read(landingPointUrl(), signal),
|
||||
]);
|
||||
return { cables, landingPoints };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { viewAreaRadiusKm } from './viewArea.js';
|
||||
|
||||
/**
|
||||
* The area a viewer is looking at, as `{ lat, lon, radiusKm }`: centered on
|
||||
* the point in the middle of the screen (or below the camera when the middle
|
||||
* shows sky), sized by the camera's height. Null for a view that wants every
|
||||
* vessel.
|
||||
*/
|
||||
export function vesselViewArea(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const scene = viewer?.scene;
|
||||
const height = camera?.positionCartographic?.height;
|
||||
const radiusKm = viewAreaRadiusKm(height);
|
||||
if (radiusKm == null) return null;
|
||||
let center = camera.positionCartographic;
|
||||
const canvas = scene?.canvas;
|
||||
if (canvas?.clientWidth && canvas?.clientHeight) {
|
||||
try {
|
||||
const hit = camera.pickEllipsoid(
|
||||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
scene.globe?.ellipsoid,
|
||||
);
|
||||
if (hit) center = Cesium.Cartographic.fromCartesian(hit);
|
||||
} catch {
|
||||
/* the camera position stands in */
|
||||
}
|
||||
}
|
||||
const lat = Cesium.Math.toDegrees(center.latitude);
|
||||
const lon = Cesium.Math.toDegrees(center.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return { lat, lon, radiusKm };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createVesselState } from './state.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { vesselViewArea } from './cameraArea.js';
|
||||
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
@@ -36,6 +37,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
feed: vesselState.state.feed,
|
||||
readSource: () => vesselState._source,
|
||||
readViewer: () => vesselState.state.viewer,
|
||||
readArea: vesselViewArea,
|
||||
getRowLimit: parts.rendering.renderRowLimit,
|
||||
readCount: () => vesselState.state.records.all.length,
|
||||
applyRows: parts.snapshots.reconcileVessels,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
|
||||
import { viewAreaMovedEnough } from './viewArea.js';
|
||||
|
||||
/** Own source requests and classified feed state through explicit operations. */
|
||||
export function createIngestion({
|
||||
feed,
|
||||
readSource,
|
||||
readViewer,
|
||||
readArea = () => null,
|
||||
getRowLimit,
|
||||
readCount,
|
||||
applyRows,
|
||||
@@ -34,8 +36,11 @@ export function createIngestion({
|
||||
AbortSignal.timeout(10000),
|
||||
])
|
||||
: requestController.signal;
|
||||
// Ask for the vessels in view; a wide view asks for every vessel.
|
||||
const area = readArea(viewer);
|
||||
feed.lastArea = area;
|
||||
const snapshot = await readSource().getSnapshot(
|
||||
{ maxRows: getRowLimit() },
|
||||
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
|
||||
{ signal },
|
||||
);
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||
@@ -169,6 +174,15 @@ export function createIngestion({
|
||||
: new Date(record.observedAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
/** Ask again when the view moved away from the last area asked for. */
|
||||
function refreshIfMoved(viewer) {
|
||||
if (!feed.enabled || feed.loading) return Promise.resolve();
|
||||
const active = viewer || readViewer();
|
||||
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
|
||||
return Promise.resolve();
|
||||
return loadLivePositions(active);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
update(viewer) {
|
||||
if (!feed.enabled) return Promise.resolve();
|
||||
@@ -178,6 +192,7 @@ export function createIngestion({
|
||||
|
||||
return {
|
||||
loadLivePositions,
|
||||
refreshIfMoved,
|
||||
ownsAisRequest,
|
||||
applyAisFeedSnapshot,
|
||||
vesselDisplayRow,
|
||||
@@ -209,5 +224,9 @@ export function createVesselFeed() {
|
||||
firstConnectDeadline: null,
|
||||
firstConnectTimer: null,
|
||||
abort: null,
|
||||
/** The area last asked for, null for every vessel. */
|
||||
lastArea: null,
|
||||
/** Stops listening for camera moves. */
|
||||
removeViewListener: null,
|
||||
};
|
||||
}
|
||||
@@ -104,3 +104,46 @@ test('vessel ingestion converts source units once and retains warm records on a
|
||||
assert.equal(probe.feed.loading, false);
|
||||
assert.equal(probe.feed.abort, null);
|
||||
});
|
||||
|
||||
test('vessels are asked for by the area in view, and again only when the view moves away', async () => {
|
||||
const asked = [];
|
||||
let area = { lat: 37.8, lon: -122.4, radiusKm: 40 };
|
||||
const feed = createVesselFeed();
|
||||
feed.enabled = true;
|
||||
feed.sessionId = 1;
|
||||
const ingestion = createIngestion({
|
||||
feed,
|
||||
readSource: () => ({
|
||||
getSnapshot: async (query) => {
|
||||
asked.push(query);
|
||||
return { records: [], source: 'Feed' };
|
||||
},
|
||||
}),
|
||||
readViewer: () => ({}),
|
||||
readArea: () => area,
|
||||
getRowLimit: () => 500,
|
||||
readCount: () => 0,
|
||||
now: () => 0,
|
||||
setSourceLabel: () => {},
|
||||
applyRows: () => {},
|
||||
classifySnapshot: () => ({
|
||||
acceptedRows: [],
|
||||
acceptedRowCount: 0,
|
||||
rawRowCount: 0,
|
||||
}),
|
||||
isDefinitiveTransportFailure: () => false,
|
||||
isGraceEligibleTransport: () => false,
|
||||
markUnavailable: () => {},
|
||||
settleFirstConnect: () => {},
|
||||
});
|
||||
await ingestion.methods.update();
|
||||
assert.deepEqual(asked, [{ maxRows: 500, area }]);
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.equal(asked.length, 1);
|
||||
area = { ...area, lat: 38.2 };
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.deepEqual(asked.at(-1), { maxRows: 500, area });
|
||||
area = null;
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.deepEqual(asked.at(-1), { maxRows: 500 });
|
||||
});
|
||||
@@ -196,10 +196,18 @@ export function createLifecycle({
|
||||
state.records.byMmsi.has(pickedId),
|
||||
);
|
||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||
// Vessels are asked for by view, so ask again when the view moves away.
|
||||
removeViewListener();
|
||||
const moveEnd = activeViewer?.camera?.moveEnd;
|
||||
if (typeof moveEnd?.addEventListener === 'function')
|
||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
||||
components.ingestion.refreshIfMoved(activeViewer);
|
||||
});
|
||||
return components.ingestion.loadLivePositions(activeViewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
removeViewListener();
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
@@ -218,6 +226,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
@@ -241,6 +250,12 @@ export function createLifecycle({
|
||||
},
|
||||
};
|
||||
|
||||
function removeViewListener() {
|
||||
const remove = state.feed.removeViewListener;
|
||||
state.feed.removeViewListener = null;
|
||||
if (typeof remove === 'function') remove();
|
||||
}
|
||||
|
||||
return {
|
||||
clearFirstConnectTimer,
|
||||
invalidateAisSession,
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createQueries({
|
||||
/**
|
||||
* Chip text for a feed the server has reported as not delivering.
|
||||
* @param {string} status - 'stale' | 'reconnecting' | 'down'
|
||||
* @param {Object} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object} payload - Parsed /api/vessels JSON.
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -61,12 +61,12 @@ export function createQueries({
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a surfaced error string from an /api/ais-live payload, or null when the
|
||||
* Derive a surfaced error string from an /api/vessels payload, or null when the
|
||||
* feed has accepted product data. Socket transport, message receipt, and usable
|
||||
* vessel positions are separate health stages: an open socket with no message
|
||||
* or no accepted positions must not read as a fresh successful update.
|
||||
*
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
|
||||
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
|
||||
* @returns {string|null} A short reason for the chip, or null if healthy.
|
||||
*/
|
||||
@@ -106,7 +106,7 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Classify one server snapshot before any destructive reconciliation.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels payload.
|
||||
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
|
||||
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
|
||||
* acceptedRowCount: number, error: string|null}}
|
||||
@@ -140,7 +140,7 @@ export function createQueries({
|
||||
* Map one internal vessel record to a plain JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
|
||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Smallest area asked for around the view, in kilometres. */
|
||||
export const VIEW_AREA_MIN_RADIUS_KM = 10;
|
||||
/** Largest area asked for; a wider view asks for every vessel instead. */
|
||||
export const VIEW_AREA_MAX_RADIUS_KM = 450;
|
||||
|
||||
/**
|
||||
* The radius worth asking for from a camera this high, in kilometres: about
|
||||
* the height, so the area covers what a camera looking down shows. Null when
|
||||
* the view is wide enough that every vessel is wanted.
|
||||
*/
|
||||
export function viewAreaRadiusKm(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
const radiusKm = heightM / 1000;
|
||||
if (radiusKm > VIEW_AREA_MAX_RADIUS_KM) return null;
|
||||
return Math.max(VIEW_AREA_MIN_RADIUS_KM, radiusKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a view moved far enough from the last area asked for to ask
|
||||
* again: its radius changed by a quarter, or its center moved a quarter of
|
||||
* the radius. Switching between an area and every vessel always counts.
|
||||
*/
|
||||
export function viewAreaMovedEnough(previous, next) {
|
||||
if (!previous || !next) return previous !== next;
|
||||
if (Math.abs(next.radiusKm - previous.radiusKm) >= previous.radiusKm * 0.25)
|
||||
return true;
|
||||
const lat = ((previous.lat + next.lat) / 2) * (Math.PI / 180);
|
||||
const dLat = (next.lat - previous.lat) * 111.32;
|
||||
const dLon = (next.lon - previous.lon) * 111.32 * Math.cos(lat);
|
||||
return Math.hypot(dLat, dLon) >= previous.radiusKm * 0.25;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
VIEW_AREA_MIN_RADIUS_KM,
|
||||
viewAreaMovedEnough,
|
||||
viewAreaRadiusKm,
|
||||
} from './viewArea.js';
|
||||
|
||||
test('the area asked for follows the camera height, and a wide view asks for every vessel', () => {
|
||||
assert.equal(viewAreaRadiusKm(2_000), VIEW_AREA_MIN_RADIUS_KM);
|
||||
assert.equal(viewAreaRadiusKm(80_000), 80);
|
||||
assert.equal(
|
||||
viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000),
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
);
|
||||
assert.equal(viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000 + 1), null);
|
||||
assert.equal(viewAreaRadiusKm(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('a view asks again only after moving or zooming a quarter of its radius', () => {
|
||||
const here = { lat: 37.8, lon: -122.4, radiusKm: 40 };
|
||||
assert.equal(viewAreaMovedEnough(null, here), true);
|
||||
assert.equal(viewAreaMovedEnough(here, null), true);
|
||||
assert.equal(viewAreaMovedEnough(null, null), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.85 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.9 }), true);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 45 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 55 }), true);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Same-site request gate for the cost-bearing and log endpoints.
|
||||
*
|
||||
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
|
||||
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
|
||||
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
|
||||
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend), and
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
|
||||
* ungated, so a hostile web page open in the same browser could reach them
|
||||
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
|
||||
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
|
||||
*
|
||||
* This module is the pure core that refuses those shapes while keeping the
|
||||
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
|
||||
* repo QA harnesses POST the token endpoint from Node with no Origin) working.
|
||||
* It deliberately does NOT require a loopback remote address (that belongs only
|
||||
* to the credential panel), the presence of an `Origin`, or a JSON
|
||||
* Content-Type. It touches nothing but its arguments, so every refusal below is
|
||||
* pinned by a unit assertion.
|
||||
*/
|
||||
/**
|
||||
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
|
||||
* not originate on this machine, whatever its socket says. This module owns
|
||||
* the list for every local gate (the credential panel and /mcp import
|
||||
* `hasProxySignals`), and has no imports so it can sit in any package boundary.
|
||||
*/
|
||||
export const PROXY_SIGNALS = Object.freeze([
|
||||
'forwarded',
|
||||
'via',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-port',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'cf-connecting-ip',
|
||||
'cf-ray',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a request carries reverse-proxy or CDN forwarding headers. `headers`
|
||||
* is keyed by lower-case header name.
|
||||
*/
|
||||
export function hasProxySignals(headers = {}) {
|
||||
return PROXY_SIGNALS.some(
|
||||
(name) => String(headers[name] || '').trim() !== '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a request's own authority (an origin string) from its protocol and
|
||||
* Host header. Unlike the credential-panel gate's localhost-restricted
|
||||
* `localAuthority`, this returns whatever Host the request actually carries —
|
||||
* the LAN opt-in serves a non-loopback Host to LAN browsers, and a same-origin
|
||||
* fetch from such a page must still match. Malformed/missing input yields null.
|
||||
* @param {string} hostHeader e.g. `req.headers.host`
|
||||
* @param {string} protocol `http:` or `https:`
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function requestAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const scheme = String(protocol || '').toLowerCase();
|
||||
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
|
||||
return null;
|
||||
try {
|
||||
return new URL(`${scheme}//${raw}`).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a request to a cost-bearing/log endpoint is same-site enough
|
||||
* to admit. Pure: no I/O, no globals.
|
||||
*
|
||||
* Policy, in order:
|
||||
* 1. any reverse-proxy / CDN signal header present (PROXY_SIGNALS) → 403;
|
||||
* 2. `Sec-Fetch-Site` present and not `same-origin` / `none` → 403 (this is
|
||||
* what blocks `<img src=...>` and cross-site navigation, which carry no
|
||||
* Origin but do carry `Sec-Fetch-Site: cross-site`);
|
||||
* 3. `Origin` present must exactly equal the request's own authority computed
|
||||
* from protocol + Host — the same exact-origin comparison the credential
|
||||
* gate uses (no userinfo, no path/search/hash). The literal string `null`
|
||||
* is an opaque origin (sandboxed iframe / `data:` URL) and is refused;
|
||||
* 4. otherwise ok. Non-browser loopback tools and the LAN opt-in (which may
|
||||
* carry neither `Origin` nor `Sec-Fetch-Site`) pass here.
|
||||
*
|
||||
* @param {{hostHeader?: string, protocol?: string, origin?: string, secFetchSite?: string, proxyHeaders?: Record<string,string>}} req
|
||||
* @returns {{ok: true} | {ok: false, status: 403, error: string}}
|
||||
*/
|
||||
export function admitSameSiteRequest({
|
||||
hostHeader,
|
||||
protocol = 'http:',
|
||||
origin,
|
||||
secFetchSite,
|
||||
proxyHeaders = {},
|
||||
} = {}) {
|
||||
// (1) A request carrying reverse-proxy / CDN forwarding headers did not
|
||||
// originate on this machine, whatever its socket says.
|
||||
if (hasProxySignals(proxyHeaders)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Proxied requests are not accepted',
|
||||
};
|
||||
}
|
||||
// (2) The browser tells us when a request is cross-site. `none` is a typed
|
||||
// URL / bookmark; `same-origin` is the app itself. Anything else (cross-site,
|
||||
// same-site but cross-origin) is refused.
|
||||
const site = String(secFetchSite || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (site !== '' && site !== 'same-origin' && site !== 'none') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Cross-site requests are not accepted',
|
||||
};
|
||||
}
|
||||
// (3) If Origin is present it must exactly equal the request's own authority.
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
if (origin === 'null') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Opaque origins are not accepted',
|
||||
};
|
||||
}
|
||||
const authority = requestAuthority(hostHeader, protocol);
|
||||
let parsedOrigin;
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
}
|
||||
const exactOrigin =
|
||||
parsedOrigin.username === '' &&
|
||||
parsedOrigin.password === '' &&
|
||||
parsedOrigin.pathname === '/' &&
|
||||
parsedOrigin.search === '' &&
|
||||
parsedOrigin.hash === '' &&
|
||||
parsedOrigin.origin === authority;
|
||||
if (!exactOrigin) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Cross-origin requests are not accepted',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { admitSameSiteRequest } from './localRequestGate.mjs';
|
||||
|
||||
const same = {
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
origin: 'http://localhost:4173',
|
||||
secFetchSite: 'same-origin',
|
||||
};
|
||||
|
||||
test('the honest same-origin browser request is admitted', () => {
|
||||
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||
});
|
||||
|
||||
test('a cross-site Origin is refused', () => {
|
||||
const r = admitSameSiteRequest({ ...same, origin: 'https://evil.example' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('an opaque Origin ("null") is refused even when Host would match', () => {
|
||||
const r = admitSameSiteRequest({ ...same, origin: 'null' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site cross-site without an Origin is refused (the <img> case)', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
secFetchSite: 'cross-site',
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site same-site (cross-origin sibling) is refused', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
...same,
|
||||
origin: 'http://localhost:4173',
|
||||
secFetchSite: 'same-site',
|
||||
});
|
||||
assert.equal(r.ok, false, 'same-site is not same-origin');
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('a reverse-proxy signal header is refused', () => {
|
||||
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
|
||||
const r = admitSameSiteRequest({ ...same, proxyHeaders: { [header]: 'anything' } });
|
||||
assert.equal(r.ok, false, `${header} present → refused`);
|
||||
}
|
||||
// An empty forwarding header is not a proxy signal.
|
||||
assert.equal(admitSameSiteRequest({ ...same, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
|
||||
});
|
||||
|
||||
test('same-origin Origin with Sec-Fetch-Site same-origin is admitted', () => {
|
||||
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||
});
|
||||
|
||||
test('no Origin and no Sec-Fetch headers (curl / Node harness) is admitted', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
});
|
||||
assert.equal(r.ok, true, 'non-browser loopback tools pass');
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site none (typed URL / bookmark) is admitted', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
secFetchSite: 'none',
|
||||
});
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('a LAN remote address is irrelevant: the function takes no remoteAddress', () => {
|
||||
// The credential-panel gate requires a loopback socket; this gate does not.
|
||||
// Passing a LAN-shaped Host/Origin pair must still match (LAN opt-in works).
|
||||
const lan = admitSameSiteRequest({
|
||||
hostHeader: '192.168.1.5:4173',
|
||||
protocol: 'http:',
|
||||
origin: 'http://192.168.1.5:4173',
|
||||
secFetchSite: 'same-origin',
|
||||
});
|
||||
assert.equal(lan.ok, true, 'a same-origin LAN browser request is admitted');
|
||||
});
|
||||
|
||||
test('a cross-port or cross-scheme Origin against the same Host is refused', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://localhost:4174' }).ok, false, 'cross-port');
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme');
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host');
|
||||
});
|
||||
|
||||
test('an unparseable Origin is refused', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'not a url' }).ok, false);
|
||||
});
|
||||
|
||||
test('a missing Host with a present Origin is refused (no authority to match)', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: '' }).ok, false, 'empty Host → null authority');
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: undefined }).ok, false, 'absent Host → null authority');
|
||||
});
|
||||
|
||||
test('a foreign Host is refused when an Origin is present', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: 'evil.example:4173' }).ok, false, 'foreign Host does not match local Origin');
|
||||
});
|
||||
@@ -841,6 +841,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
||||
query,
|
||||
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
||||
signal,
|
||||
placeSearch,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (recovered) {
|
||||
|
||||
@@ -599,6 +599,21 @@ test('search without an authority hook preserves the existing caller contract',
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
});
|
||||
|
||||
test('near-view recovery searches through the application place search', async () => {
|
||||
// Recovery must use the place search the application configured, not the module default.
|
||||
const viewer = stubViewer();
|
||||
const placeSearch = createStandalonePlaceSearch({ resolveApiKey: () => 'test-key' });
|
||||
let received = null;
|
||||
await runSearch(viewer, {
|
||||
placeSearch,
|
||||
recoverNearView: async (...args) => {
|
||||
received = args[4];
|
||||
return null;
|
||||
},
|
||||
});
|
||||
assert.equal(received, placeSearch);
|
||||
});
|
||||
|
||||
test('a precise search without an outline frames against the resolved ground, not sea level', async () => {
|
||||
// Camp Mabry field report: with no detailed outline (no configured Overpass)
|
||||
// framing used the 250 m landmark range from a sea-level target, and the eye
|
||||
|
||||
+77
-1
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
|
||||
return {
|
||||
calls,
|
||||
Ion: { defaultAccessToken: undefined },
|
||||
GoogleMaps: { defaultApiKey: undefined },
|
||||
GoogleMaps: {
|
||||
defaultApiKey: undefined,
|
||||
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
|
||||
getDefaultCredit: () => undefined,
|
||||
},
|
||||
Resource: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
async createGooglePhotorealistic3DTileset(options) {
|
||||
calls.push({ options, googleKey: options.key });
|
||||
return next();
|
||||
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
|
||||
async fromUrl(resource, options) {
|
||||
calls.push({
|
||||
options,
|
||||
resource,
|
||||
ionToken: resource.accessToken,
|
||||
assetId: resource.assetId,
|
||||
});
|
||||
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
||||
});
|
||||
|
||||
function tokenSource(...issued) {
|
||||
const requests = [];
|
||||
return {
|
||||
requests,
|
||||
async token(options = {}) {
|
||||
requests.push(options);
|
||||
return issued.shift() ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('without a key, server tokens load Google 3D directly', async () => {
|
||||
const tileset = { id: 'token' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleTokens: tokenSource('short-lived'),
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.tileset, tileset);
|
||||
assert.equal(result.route, 'google-token');
|
||||
const { resource, options } = Cesium.calls[0];
|
||||
assert.equal(
|
||||
resource.url,
|
||||
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||
);
|
||||
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
|
||||
assert.equal(resource.queryParameters, undefined);
|
||||
assert.equal(options.enableCollision, true);
|
||||
});
|
||||
|
||||
test('a tile refused for an expired token retries once with a renewed one', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'token' }]);
|
||||
const tokens = tokenSource('first', 'second');
|
||||
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
|
||||
const { resource } = Cesium.calls[0];
|
||||
const tile = { headers: { ...resource.headers } };
|
||||
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
|
||||
assert.equal(tile.headers.Authorization, 'Bearer second');
|
||||
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
|
||||
assert.equal(resource.retryAttempts, 1);
|
||||
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
|
||||
});
|
||||
|
||||
test('a key takes precedence over server tokens', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'direct' }]);
|
||||
const tokens = tokenSource('short-lived');
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey: 'google-secret',
|
||||
googleTokens: tokens,
|
||||
});
|
||||
assert.equal(result.route, 'google-direct');
|
||||
assert.equal(tokens.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a server without tokens falls back to ion', async () => {
|
||||
const tileset = { id: 'ion' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleTokens: tokenSource(),
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.route, 'google-ion');
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.equal(Cesium.calls.length, 1);
|
||||
});
|
||||
+56
-10
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. Direct
|
||||
* access uses a browser key, or else short-lived tokens from the app's
|
||||
* server (see googleTokens.js). If direct access fails and an ion token is
|
||||
* available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
if (googleKey)
|
||||
attempts.push({
|
||||
route: 'google-direct',
|
||||
create: () => createGoogleDirectTileset(Cesium, googleKey),
|
||||
});
|
||||
else if (googleTokens)
|
||||
attempts.push({
|
||||
route: 'google-token',
|
||||
create: () => createGoogleTokenTileset(Cesium, googleTokens),
|
||||
});
|
||||
if (ionToken)
|
||||
attempts.push({
|
||||
route: 'google-ion',
|
||||
create: () => createGoogleIonTileset(Cesium, ionToken),
|
||||
});
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
const tileset = await attempt.create();
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 3D with short-lived tokens instead of a key. Every tile request
|
||||
* carries the token as a header; a tile refused for an expired token gets
|
||||
* the renewed one and is tried once more.
|
||||
*/
|
||||
export async function createGoogleTokenTileset(Cesium, tokens) {
|
||||
const token = await tokens.token();
|
||||
if (!token) throw new Error('Google 3D tokens are not offered');
|
||||
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
|
||||
const resource = new Cesium.Resource({
|
||||
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credits: credit ? [credit] : undefined,
|
||||
retryAttempts: 1,
|
||||
async retryCallback(failed, error) {
|
||||
if (![401, 403].includes(error?.statusCode)) return false;
|
||||
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
|
||||
const renewed = await tokens.token({ replacing: used });
|
||||
if (!renewed || renewed === used) return false;
|
||||
failed.headers.Authorization = `Bearer ${renewed}`;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
// The settings Cesium's Google helper applies to the key route.
|
||||
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||
cacheBytes: 1536 * 1024 * 1024,
|
||||
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||
enableCollision: true,
|
||||
// Tiles keep drawing their own texture while draped weather loads.
|
||||
asynchronouslyLoadImagery: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGoogleIonTileset(
|
||||
Cesium,
|
||||
accessToken,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Short-lived Google access tokens the app's server may offer in place of a
|
||||
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
|
||||
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
|
||||
* answer means it does not, and the app carries on without them.
|
||||
*/
|
||||
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
|
||||
|
||||
/** Renew a token this long before it expires. */
|
||||
const RENEW_EARLY_MS = 60_000;
|
||||
|
||||
function parseToken(data, now) {
|
||||
const accessToken = data?.accessToken;
|
||||
const expiresAt = Number(data?.expiresAt);
|
||||
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
|
||||
return { accessToken, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of tokens from the app's server. `token()` resolves the current
|
||||
* token, renewing it shortly before it expires; `token({ replacing })`
|
||||
* renews only if `replacing` is still the current token, so many tile
|
||||
* requests rejected for the same expired token renew it once. Resolves null
|
||||
* when the server does not offer tokens.
|
||||
*/
|
||||
export function createGoogleTokenSource({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
url = GOOGLE_TOKEN_PATH,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let pending = null;
|
||||
|
||||
async function request() {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return parseToken(await response.json(), now());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async token({ replacing } = {}) {
|
||||
const usable =
|
||||
current &&
|
||||
current.expiresAt - RENEW_EARLY_MS > now() &&
|
||||
(replacing === undefined || current.accessToken !== replacing);
|
||||
if (usable) return current.accessToken;
|
||||
pending ??= request().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
current = await pending;
|
||||
return current?.accessToken ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
|
||||
|
||||
function server(answers) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
const answer = answers.shift();
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const offer = (accessToken, expiresAt) =>
|
||||
Response.json({ accessToken, expiresAt });
|
||||
|
||||
test('a token is reused until shortly before it expires', async () => {
|
||||
let now = 1_000_000;
|
||||
const { calls, fetchImpl } = server([
|
||||
offer('first', now + 900_000),
|
||||
offer('second', now + 1_800_000),
|
||||
]);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||
assert.equal(await tokens.token(), 'first');
|
||||
assert.equal(await tokens.token(), 'first');
|
||||
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
|
||||
now += 850_000;
|
||||
assert.equal(await tokens.token(), 'second');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('requests refused for the same token renew it once', async () => {
|
||||
const now = 1_000_000;
|
||||
const { calls, fetchImpl } = server([
|
||||
offer('first', now + 900_000),
|
||||
offer('second', now + 900_000),
|
||||
]);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||
await tokens.token();
|
||||
const renewed = await Promise.all([
|
||||
tokens.token({ replacing: 'first' }),
|
||||
tokens.token({ replacing: 'first' }),
|
||||
]);
|
||||
assert.deepEqual(renewed, ['second', 'second']);
|
||||
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('a server that does not offer tokens yields none', async () => {
|
||||
for (const answer of [
|
||||
new Response('Not found', { status: 404 }),
|
||||
new Response('<!doctype html>', { status: 200 }),
|
||||
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
|
||||
Response.json({ accessToken: 'stale', expiresAt: 1 }),
|
||||
new TypeError('offline'),
|
||||
]) {
|
||||
const { fetchImpl } = server([answer]);
|
||||
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
|
||||
}
|
||||
});
|
||||
@@ -3,16 +3,19 @@
|
||||
* data files, line-delimited GeoJSON parsing and analyst records.
|
||||
*/
|
||||
|
||||
// Resolved by Vite in builds and relative to this module in other consumers.
|
||||
// Resolved by Vite in builds and relative to this module in other consumers,
|
||||
// when read, so importing this module never needs a module URL.
|
||||
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
|
||||
'local-datacenters': new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href,
|
||||
'local-dams': new URL(
|
||||
'../data/local_data/dams/dams.geojsonl',
|
||||
import.meta.url,
|
||||
).href,
|
||||
get 'local-datacenters'() {
|
||||
return new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
get 'local-dams'() {
|
||||
return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
|
||||
.href;
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse GeoJSON Lines: one Feature per non-empty line. */
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user