From 428a6d733222a2ec286052ce5a7295b6e3e82a8f Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:13:39 -0500 Subject: [PATCH 01/10] docs(cctv): state what the app does with camera frames (#511) Supersedes #357 by @Lob26, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Pedro Lobato <69770518+Lob26@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ DATA_SOURCES.md | 1 + 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0242199c9..d31b48611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -223,6 +223,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- `DATA_SOURCES.md` states what the project does with camera frame content: a + successful upstream response is relayed as the provider served it, nothing in + the camera pipeline enhances it or recognises what is in it, resampling for + display is the only change made to the picture, and no frame is written to disk. + It also names what a viewer sees when an upstream has no frame — including a + last good picture kept after a failed refresh — and the one feature that sends + imagery anywhere: the voice assistant's viewport screenshot. Contributed by + Lob26 (#357). + - Extract panel disclosure and hover/focus controls into a reusable module; cancel their listeners and pending work during replacement and teardown. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 08a3d662e..f0bc95cb5 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -63,6 +63,7 @@ How to read this: - **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`. - **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`. +- **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. - **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. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. From da700be77f2864f7794d3acc09f6fcc956e2441c Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:17:52 -0500 Subject: [PATCH 02/10] fix(cctv): bound the Range header and release abandoned camera streams (#512) Supersedes #253 by @Maher-Reven, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Maher Kurdi <72787268+Maher-Reven@users.noreply.github.com> --- CHANGELOG.md | 18 + SECURITY.md | 1 + scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 1 + server/providers/cctv.js | 25 +- server/providers/cctv/media.js | 63 ++++ server/providers/cctv/range.js | 81 +++++ src/data/cctvMediaRange.test.mjs | 588 +++++++++++++++++++++++++++++++ 8 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 server/providers/cctv/range.js create mode 100644 src/data/cctvMediaRange.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d31b48611..41ba9bee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,24 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Security +- The CCTV media route no longer forwards a client `Range` header to the upstream + camera host as it arrived. A single `bytes=` range is canonicalized and + forwarded, with every accepted form — explicit span, open-ended and suffix — + bounded to 64 MiB, the ceiling the relay already applies to a response that + declares its length. A response that declares no length has no ceiling — live + streamed media, and anything an upstream sends chunked while ignoring the + `Range` — which is unchanged. Multi-range, malformed, inverted, non-`bytes` and + unsafe-integer values are dropped and the request proceeds without a `Range`, + as RFC 7233 §3.1 prescribes; a multi-range value previously invited a + `multipart/byteranges` answer, whose parts nothing here reads. A value carrying + CR or LF made the outbound request throw, and the route recorded the thrown + message — which contains the caller's own string — as that camera's entry in + the health report. A request the browser has stopped waiting for is now + released: whether the viewer leaves while the camera is still answering or + part-way through the picture, the upstream request is cancelled rather than + left running, and neither case marks the camera degraded. Ordinary seeking is + unaffected. Contributed by Maher-Reven (#253). + - Validate configured Google Places coordinates and text queries before rate limiting or upstream requests; preserve the keyless capability response. - Bound CCTV media response headers to 15 seconds and cancel error bodies. diff --git a/SECURITY.md b/SECURITY.md index 20fee1a36..9bf718b74 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts. - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. +- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5f27f229c..41dfb629b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -612,5 +612,7 @@ "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/records.test.mjs", "src/layers/vessels/ingestion.test.mjs", - "src/cityPresets.test.mjs" + "src/cityPresets.test.mjs", + "server/providers/cctv/range.js", + "src/data/cctvMediaRange.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 202159eab..8801d5c47 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -152,6 +152,7 @@ "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", + "server/providers/cctv/range.js", "server/providers/cctv/sources.js", "src/data/directionText.js", "server/providers/common/geo.js", diff --git a/server/providers/cctv.js b/server/providers/cctv.js index e1c2d8f85..5f601532f 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -10,11 +10,13 @@ import { fetchCctvImageFromUpstream, fetchTxdotSnapshot, fetchCctvMediaUpstream, + watchDownstreamClose, } from './cctv/media.js'; import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_MAX_SOURCES_CEILING, } from './cctv/constants.js'; +import { sanitizeCctvRangeHeader } from './cctv/range.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; /** @@ -218,15 +220,31 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } + // Bound before the request goes out: most of the wait is before any + // header arrives, and a viewer who leaves during it must take the + // upstream request with them. + const downstream = watchDownstreamClose(res); try { const upstreamHeaders = { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', }; - const requestRange = req.headers?.range; + // Never forward the client's own string: a Range this proxy does + // not accept is dropped and the request proceeds without one. + const requestRange = sanitizeCctvRangeHeader(req.headers?.range); if (requestRange) upstreamHeaders.Range = requestRange; const upstream = await fetchCctvMediaUpstream(mediaUrl, { headers: upstreamHeaders, + signal: downstream.signal, }); + if (downstream.closed) { + // The headers arrived for a viewer who is no longer there. + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } + return; + } const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok) { try { @@ -283,6 +301,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }); return; } catch (error) { + if (downstream.closed) { + // The viewer left mid-request. That is not a camera fault and + // there is nobody to answer. + return; + } const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError'; setHealth(cameraId, { diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 87e982f3b..d652f7150 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -156,9 +156,65 @@ export async function proxyMediaResponse( stream.on('error', () => { if (!res.writableEnded) res.end(); }); + + // A live camera feed has no end of its own. When the viewer goes away the + // upstream connection must go with it, or every abandoned view leaves a + // stream open against the camera host for as long as that host will hold it. + let released = false; + const releaseUpstream = () => { + if (released) return; + released = true; + stream.unpipe(res); + // Destroying the Node stream cancels the web body it wraps; the direct + // cancel covers a body that was never wrapped, and rejects harmlessly when + // the reader is already held. + stream.destroy(); + try { + const cancelled = upstream.body?.cancel?.(); + if (typeof cancelled?.catch === 'function') cancelled.catch(() => {}); + } catch { + /* already closed */ + } + }; + res.once('close', () => { + if (!res.writableEnded) releaseUpstream(); + }); + res.once('error', releaseUpstream); + stream.once('end', () => { + released = true; + }); stream.pipe(res); } +/** + * Watch a client response for an early goodbye. + * + * Bound BEFORE the upstream request goes out, because most of the waiting + * happens before any header comes back: a viewer who closes the tab while a + * slow camera is still thinking would otherwise leave that request running with + * nobody to receive it. + * + * @param {import('http').ServerResponse} res - The client response. + * @returns {{signal: AbortSignal, closed: boolean}} `signal` cancels the + * upstream request; `closed` says the client left before the response ended. + */ +export function watchDownstreamClose(res) { + const controller = new AbortController(); + const state = { + signal: controller.signal, + closed: false, + }; + const onClose = () => { + // A response that ended normally also emits close; only an early one counts. + if (res.writableEnded) return; + state.closed = true; + controller.abort(); + }; + res.once?.('close', onClose); + res.once?.('error', onClose); + return state; +} + /** Read a snapshot incrementally, retaining at most maxBytes of owned chunks. */ async function readCappedResponseBytes(upstream, maxBytes) { const declared = Number(upstream.headers.get('content-length')); @@ -229,14 +285,21 @@ export async function fetchCctvMediaUpstream( headers = {}, fetchImpl = fetch, timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS, + signal: downstream = null, } = {}, ) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + // The client going away cancels the upstream request, not just the response + // to it. + const onDownstreamAbort = () => controller.abort(); + if (downstream?.aborted) controller.abort(); + else downstream?.addEventListener?.('abort', onDownstreamAbort); try { return await fetchImpl(url, { headers, signal: controller.signal }); } finally { clearTimeout(timeoutId); + downstream?.removeEventListener?.('abort', onDownstreamAbort); } } diff --git a/server/providers/cctv/range.js b/server/providers/cctv/range.js new file mode 100644 index 000000000..d21e7dba5 --- /dev/null +++ b/server/providers/cctv/range.js @@ -0,0 +1,81 @@ +import { CCTV_MEDIA_MAX_BODY_BYTES } from './constants.js'; + +/** Digits allowed per Range position — bounds parse cost and absurd offsets. */ +const RANGE_MAX_DIGITS = 16; + +/** + * Validate, canonicalize and bound a client `Range` header before the media + * route forwards it to a third-party camera host. + * + * The route used to copy `req.headers.range` through verbatim. Two things + * follow from that. A multi-range value makes the upstream answer + * `multipart/byteranges`: when that body declares its length the relay's + * ceiling still applies to it, but the parts inside are the upstream's to + * choose and nothing here reads them, and a multipart body sent without a + * declared length streams through uncapped like any other length-less body. + * And a value the outbound request refuses to carry — anything with CR or LF + * in it — made `fetch` throw, and the route recorded that error text, which + * contains the client's own string, as the camera's health message. + * + * Bounding the Range bounds what is ASKED FOR, not what arrives: an upstream + * that ignores the Range and answers with a length-less chunked body still + * streams without a ceiling. That is how live feeds are served and is not + * changed here. What is bounded is the request's lifetime: it is cancelled when + * the viewer goes away, before the headers arrive as well as during the body. + * + * Anything that is not a single well-formed `bytes=` range is DROPPED and the + * request proceeds with no Range, which is what RFC 7233 §3.1 prescribes for a + * Range a server cannot understand: the client gets the whole body rather than + * an error it did not ask for. Accepted forms are `bytes=-`, + * `bytes=-` and `bytes=-`. + * + * Every accepted form is bounded to {@link CCTV_MEDIA_MAX_BODY_BYTES}, the same + * ceiling the relay applies to a declared response body — including the + * open-ended and suffix forms, which otherwise ask an upstream for a whole file + * of unknown size. A player that wants more than one bound's worth asks for the + * next range, which is ordinary partial-content behavior. + * + * @param {*} value - Raw `req.headers.range`. + * @param {number} [maxBytes] - Ceiling for the requested span. + * @returns {string} Canonical `bytes=...` value, or '' to send no Range. + */ +export function sanitizeCctvRangeHeader( + value, + maxBytes = CCTV_MEDIA_MAX_BODY_BYTES, +) { + if (typeof value !== 'string') return ''; + const raw = value.trim(); + if (!raw) return ''; + + // The unit is case-insensitive (RFC 7233 §2.1) and `bytes` is the only one + // this proxy understands. A comma anywhere fails this pattern, which is how + // multi-range is refused. + const match = /^bytes=(\d*)-(\d*)$/i.exec(raw); + if (!match) return ''; + + const [, firstText, lastText] = match; + if (firstText.length > RANGE_MAX_DIGITS || lastText.length > RANGE_MAX_DIGITS) + return ''; + // "bytes=-" carries neither position and is meaningless. + if (!firstText && !lastText) return ''; + + // Suffix form: the final N bytes. N === 0 is unsatisfiable by definition. + if (!firstText) { + const suffix = Number(lastText); + if (!Number.isSafeInteger(suffix) || suffix <= 0) return ''; + return `bytes=-${Math.min(suffix, maxBytes)}`; + } + + const first = Number(firstText); + if (!Number.isSafeInteger(first) || first < 0) return ''; + + const ceiling = first + maxBytes - 1; + if (!Number.isSafeInteger(ceiling)) return ''; + + // Open-ended: everything from `first` on, bounded to one span. + if (!lastText) return `bytes=${first}-${ceiling}`; + + const last = Number(lastText); + if (!Number.isSafeInteger(last) || last < first) return ''; + return `bytes=${first}-${Math.min(last, ceiling)}`; +} diff --git a/src/data/cctvMediaRange.test.mjs b/src/data/cctvMediaRange.test.mjs new file mode 100644 index 000000000..3e71d3e17 --- /dev/null +++ b/src/data/cctvMediaRange.test.mjs @@ -0,0 +1,588 @@ +// Client `Range` handling for the CCTV media proxy. The route used to relay +// req.headers.range verbatim to a third-party upstream; these pin what is now +// allowed through, both as string policy and through the mounted route with an +// injected upstream. +// +// Run with: npm test (node --test) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { Readable } from 'node:stream'; +import { cctvProxy } from '../../server/providers/cctv.js'; +import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js'; +import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js'; + +test('single well-formed byte ranges pass through canonicalized', () => { + assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023'); + // A single range that names the same byte twice is legal. + assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7'); + // The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal. + assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99'); +}); + +test('multi-range is dropped so the upstream never answers multipart/byteranges', () => { + // The relay forwards Content-Length/Content-Range and caps on a single + // declared body; a multipart response defeats that accounting. + assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), ''); + assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), ''); +}); + +test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => { + for (const bad of [ + 'bytes=abc-def', + 'bytes=', + 'bytes=-', + 'bytes=1-2-3', + '0-100', // no unit + 'items=0-100', // a unit the proxy does not understand + 'bytes=1.5-2', + 'bytes=-0', // zero-length suffix is unsatisfiable + 'bytes=0x10-0x20', + 'bytes=+5-10', + 'bytes=-5-10', + // An outbound request refuses to carry these, and the failure used to be + // written into the camera's health message. + 'bytes=0-10\r\nX-Injected: 1', + 'bytes=0-10\nHost: evil.example', + ]) { + assert.equal( + sanitizeCctvRangeHeader(bad), + '', + `expected drop: ${JSON.stringify(bad)}`, + ); + } +}); + +test('non-string and empty inputs yield no Range header', () => { + // req.headers.range is undefined on a normal GET, and Node hands back an + // array when a client sends the header twice. + assert.equal(sanitizeCctvRangeHeader(undefined), ''); + assert.equal(sanitizeCctvRangeHeader(null), ''); + assert.equal(sanitizeCctvRangeHeader(''), ''); + assert.equal(sanitizeCctvRangeHeader(' '), ''); + assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), ''); + assert.equal(sanitizeCctvRangeHeader(42), ''); +}); + +test('every accepted form is bounded to the relay body cap', () => { + // Explicit span. + assert.equal( + sanitizeCctvRangeHeader('bytes=0-999999999999'), + `bytes=0-${CAP - 1}`, + ); + assert.equal( + sanitizeCctvRangeHeader('bytes=1000-999999999999'), + `bytes=1000-${1000 + CAP - 1}`, + ); + // Open-ended: bounded too, rather than asking an upstream for a whole file + // of unknown size. + assert.equal( + sanitizeCctvRangeHeader('bytes=500-'), + `bytes=500-${500 + CAP - 1}`, + ); + // Suffix: bounded to one span of the tail. + assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500'); + assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`); + // Exactly at the cap, and comfortably under it, are untouched. + assert.equal( + sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`), + `bytes=0-${CAP - 1}`, + ); + assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575'); +}); + +test('inverted and unsafe-integer positions are dropped', () => { + assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), ''); + // Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision. + assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), ''); + // Absurd digit counts are refused before any Number() conversion. + assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), ''); +}); + +// --------------------------------------------------------------------------- +// The mounted route, with the upstream injected through globalThis.fetch. +// --------------------------------------------------------------------------- + +const CAMERA = { + id: 'range-fixture', + name: 'Range fixture', + lat: 30.27, + lon: -97.74, + feedType: 'video', + url: 'https://upstream.invalid/live.mp4', +}; + +/** A minimal upstream Response with a streaming body. */ +function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) { + const store = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null }, + body: + body === null + ? null + : Readable.toWeb(Readable.from([Buffer.from(String(body))])), + arrayBuffer: async () => Buffer.from(String(body)), + }; +} + +/** Collects what a handler wrote, the way a Node ServerResponse would. */ +function recordingResponse() { + const chunks = []; + let finished; + const done = new Promise((resolve) => { + finished = resolve; + }); + const res = { + statusCode: 0, + headers: {}, + writableEnded: false, + body: '', + writeHead(status, headers) { + res.statusCode = status; + res.headers = headers || {}; + }, + write(chunk) { + chunks.push(Buffer.from(chunk)); + return true; + }, + end(chunk) { + if (chunk) chunks.push(Buffer.from(chunk)); + res.writableEnded = true; + res.body = Buffer.concat(chunks).toString('utf8'); + finished(); + }, + on() {}, + once() {}, + emit() {}, + destroy() {}, + }; + return { res, done }; +} + +/** + * Mount the real provider, serve one camera, and answer its upstream from the + * supplied function. + */ +function mount(t, respond) { + const before = { + json: process.env.CCTV_SOURCES_JSON, + file: process.env.CCTV_SOURCES_FILE, + austin: process.env.CCTV_FORCE_AUSTIN, + }; + process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]); + process.env.CCTV_SOURCES_FILE = 'absent-source-file.json'; + process.env.CCTV_FORCE_AUSTIN = '0'; + const nativeFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init = {}) => { + requests.push({ + url: String(url), + headers: init.headers || {}, + signal: init.signal || null, + }); + return respond({ url: String(url), init }); + }; + t.after(() => { + globalThis.fetch = nativeFetch; + for (const [name, value] of [ + ['CCTV_SOURCES_JSON', before.json], + ['CCTV_SOURCES_FILE', before.file], + ['CCTV_FORCE_AUSTIN', before.austin], + ]) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + let handler = null; + const plugin = cctvProxy(); + plugin.configureServer({ + middlewares: { + use: (_route, fn) => { + handler = fn; + }, + }, + }); + assert.ok(handler, 'the CCTV provider must mount a middleware'); + + /** Drive one request through the mounted handler. */ + const call = async (path, headers = {}) => { + const { res, done } = recordingResponse(); + await handler({ url: path, headers, method: 'GET', on() {} }, res); + await done; + return res; + }; + /** Read the camera's current health entry. */ + const health = async () => { + const res = await call('/health'); + return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id); + }; + return { call, health, requests, handler: (req, res) => handler(req, res) }; +} + +test('a canonical range reaches the upstream and its 206 is passed through', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-range': 'bytes 0-1023/4096', + 'content-length': '1024', + 'accept-ranges': 'bytes', + }, + body: 'partial', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023'); + assert.equal(res.statusCode, 206); + assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096'); + assert.equal(res.headers['Accept-Ranges'], 'bytes'); + assert.equal(res.body, 'partial'); +}); + +test('an unbounded seek is bounded before it is forwarded', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { 'content-type': 'video/mp4' }, + body: 'tail', + }), + ); + await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' }); + assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`); +}); + +test('a rejected range is dropped and the request proceeds without one', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-10\r\nX-Injected: 1', + }); + // Documented choice: drop the header rather than answer 400, so a client + // that sends something this proxy will not carry still gets its media. + assert.equal(app.requests.at(-1).headers.Range, undefined); + assert.equal(res.statusCode, 200); + assert.equal(res.body, 'whole body'); +}); + +test('a rejected range leaves the camera health report untouched', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-10\r\nX-Injected: 1', + }); + const entry = await app.health(); + assert.equal(entry.status, 'ok'); + // The client's own string must never appear in a report other callers read. + assert.doesNotMatch(JSON.stringify(entry), /X-Injected/); +}); + +test('a multi-range request is served whole rather than as multipart', async (t) => { + const app = mount(t, ({ init }) => + upstreamResponse({ + status: init?.headers?.Range ? 206 : 200, + headers: { + 'content-type': init?.headers?.Range + ? 'multipart/byteranges; boundary=x' + : 'video/mp4', + }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-1,2-3', + }); + assert.equal(app.requests.at(-1).headers.Range, undefined); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'video/mp4'); +}); + +test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 416, + headers: { 'content-range': 'bytes */1024' }, + body: null, + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: `bytes=${2 ** 40}-${2 ** 40 + 10}`, + }); + assert.equal(res.statusCode, 416); + assert.match(res.body, /416/); + const entry = await app.health(); + assert.equal(entry.status, 'degraded'); + assert.equal(entry.message, 'Upstream HTTP 416'); +}); + +test('an upstream that ignores the range still serves its whole body', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4', 'content-length': '10' }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023'); + assert.equal(res.statusCode, 200); + assert.equal(res.body, 'whole body'); +}); + +test('an oversized declared body is refused even when a bounded range asked for it', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-length': String(CAP + 1), + }, + body: 'too big', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(res.statusCode, 502); + assert.match(res.body, /exceeds size cap/); +}); + +test('a client that disconnects mid-stream stops the upstream read', async (t) => { + // A live feed with no end of its own, counting what it is asked to produce. + let produced = 0; + let stopped = false; + const endlessBody = () => + Readable.toWeb( + Readable.from( + (async function* () { + try { + for (;;) { + produced += 1; + yield Buffer.from('frame-chunk-'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } finally { + stopped = true; + } + })(), + ), + ); + const app = mount(t, () => ({ + ok: true, + status: 206, + headers: { + get: (name) => + String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null, + }, + body: endlessBody(), + arrayBuffer: async () => Buffer.alloc(0), + })); + + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const failures = []; + const onUncaught = (error) => failures.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + + await new Promise((resolve, reject) => { + const request = http.get( + { + host: '127.0.0.1', + port, + path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'), + headers: { Range: 'bytes=0-1023' }, + }, + (response) => { + response.once('data', () => { + // Walk away mid-body, the way a browser does when the operator + // switches camera. + request.destroy(); + resolve(); + }); + response.on('error', () => resolve()); + }, + ); + request.on('error', reject); + }); + + await new Promise((resolve) => setTimeout(resolve, 150)); + const afterDisconnect = produced; + await new Promise((resolve) => setTimeout(resolve, 400)); + // The point: the camera host is no longer being read from. Without the + // release this counter keeps climbing for as long as the upstream will feed. + assert.equal( + produced, + afterDisconnect, + `the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`, + ); + assert.equal(stopped, true, 'the upstream body was never released'); + assert.deepEqual( + failures.map((error) => error?.message), + [], + 'a client walking away must not fault the server', + ); + const entry = await app.health(); + // The camera was serving fine; the client left. Nothing about that is a + // camera fault other viewers should see. + assert.equal(entry.status, 'ok'); +}); + +test('a client that leaves before the headers arrive cancels the upstream request', async (t) => { + // The camera is slow, or dead: nothing comes back until the request is + // cancelled. Everything worth testing here happens before any header. + let aborted = false; + const app = mount(t, ({ init }) => { + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }); + }); + + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const failures = []; + const onUncaught = (error) => failures.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + + const request = http.get({ + host: '127.0.0.1', + port, + path: `/api/cctv/media/${CAMERA.id}`, + headers: { Range: 'bytes=0-1023' }, + }); + request.on('error', () => {}); + // Let the route reach its upstream request, then walk away before it answers. + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(app.requests.length, 1, 'the upstream request went out'); + assert.equal(aborted, false, 'nothing was cancelled while the client waited'); + request.destroy(); + await new Promise((resolve) => setTimeout(resolve, 250)); + + assert.equal(aborted, true, 'the upstream request outlived the client'); + assert.deepEqual( + failures.map((error) => error?.message), + [], + 'a client leaving early must not fault the server', + ); + // The camera did nothing wrong; the viewer left. + const entry = await app.health(); + assert.notEqual(entry?.status, 'degraded'); +}); + +test('a folded CR/LF range never reaches the upstream through a real client', async (t) => { + // Node's own parser, not a hand-built headers object, so the reachability of + // this shape through an ordinary HTTP request is shown rather than assumed. + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + // A client can fold a value across a continuation line; Node's parser + // rejoins it, so what the route sees contains whitespace a header cannot + // carry back out. + const raw = await new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { + socket.write( + `GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` + + 'Host: 127.0.0.1\r\n' + + 'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' + + 'Connection: close\r\n\r\n', + ); + }); + let text = ''; + socket.on('data', (chunk) => { + text += chunk.toString('utf8'); + }); + socket.on('end', () => resolve(text)); + socket.on('error', reject); + }); + + // Either Node's parser refuses the request outright, or it reaches the route + // and the route drops the value — but it must never be forwarded. + const reachedRoute = app.requests.length > 0; + assert.match(raw, /^HTTP\/1\.1 (200|400)/); + if (reachedRoute) { + assert.equal( + app.requests.at(-1).headers.Range, + undefined, + 'nothing was forwarded', + ); + assert.match(raw, /^HTTP\/1\.1 200/); + } else { + assert.match(raw, /^HTTP\/1\.1 400/); + } + console.log( + ` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`, + ); + const entry = await app.health(); + assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/); +}); + +test('two sequential seeks are served as two partial responses', async (t) => { + const app = mount(t, ({ init }) => { + const range = init?.headers?.Range || ''; + const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || []; + return upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-range': `bytes ${first}-${last}/4096`, + 'content-length': String(Number(last) - Number(first) + 1), + 'accept-ranges': 'bytes', + }, + body: `chunk-${first}`, + }); + }); + const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' }); + const second = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=100-199', + }); + assert.equal(app.requests[0].headers.Range, 'bytes=0-99'); + assert.equal(app.requests[1].headers.Range, 'bytes=100-199'); + assert.equal(first.statusCode, 206); + assert.equal(second.statusCode, 206); + assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096'); + assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096'); + assert.equal(first.body, 'chunk-0'); + assert.equal(second.body, 'chunk-100'); +}); From 8702458cf626629cf319f494a1f17b23025f3fb6 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:21:38 -0500 Subject: [PATCH 03/10] perf: subset the icon font and drop the unused second family (#513) Supersedes #239 by @mml-studio, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Memel <255658710+mml-studio@users.noreply.github.com> --- CHANGELOG.md | 9 +++ index.html | 7 +- scripts/format-scope.json | 3 +- src/materialSymbolsSubset.test.mjs | 111 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/materialSymbolsSubset.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ba9bee0..a55642c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -292,6 +292,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Changed + +- The interface asks Google Fonts for only the icon glyphs it draws, instead of + the whole variable icon font, and no longer requests a second icon family that + nothing renders. A check fails when a source names a glyph the request is + missing, because an absent glyph does not draw a placeholder — the element + renders the glyph's name as text. The check reads the panel templates as well + as the scripts, and reads glyph names written as literals, so a glyph chosen + through a variable has to be added to the request by hand. Contributed by + mml-studio (#239). - Separate explicit browser build settings from standalone environment loading and local provider middleware. Preserve provider behavior and root named exports. - Rename standalone browser startup to `src/standalone/` and add a Node-only diff --git a/index.html b/index.html index 4ab989de7..2e630c4a8 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,11 @@ - - + + diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 41dfb629b..7c192c15e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -614,5 +614,6 @@ "src/layers/vessels/ingestion.test.mjs", "src/cityPresets.test.mjs", "server/providers/cctv/range.js", - "src/data/cctvMediaRange.test.mjs" + "src/data/cctvMediaRange.test.mjs", + "src/materialSymbolsSubset.test.mjs" ] diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs new file mode 100644 index 000000000..ec9ffe2f0 --- /dev/null +++ b/src/materialSymbolsSubset.test.mjs @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const REPO_ROOT = path.resolve(SRC_ROOT, '..'); +const INDEX_HTML = path.join(REPO_ROOT, 'index.html'); + +/** The glyph written as element text: `radar`. */ +const SPAN_TEXT = + /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*} glyph -> the first file that names it. + */ +function referencedGlyphs() { + const found = new Map(); + for (const file of sourceFiles()) { + const source = readFileSync(file, 'utf8'); + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + const add = (glyph) => { + if (!found.has(glyph)) found.set(glyph, relative); + }; + for (const match of source.matchAll(SPAN_TEXT)) add(match[1]); + for (const match of source.matchAll(TEXT_ASSIGNMENT)) { + const statement = match[1].replaceAll('?.', '.'); + const assigned = statement.includes('?') + ? statement.slice(statement.indexOf('?') + 1) + : statement; + for (const literal of assigned.matchAll(STRING_LITERAL)) add(literal[1]); + } + } + return found; +} + +/** The `icon_names` list index.html asks Google for. */ +function subsettedGlyphs(html = readFileSync(INDEX_HTML, 'utf8')) { + const match = + /Material\+Symbols\+Outlined[^"]*[?&]icon_names=([a-z0-9_,]+)/.exec(html); + assert.ok( + match, + 'index.html must request Material Symbols with an icon_names subset', + ); + return new Set(match[1].split(',')); +} + +test('every glyph the sources render is in the icon_names subset', () => { + const subset = subsettedGlyphs(); + const missing = [...referencedGlyphs()] + .filter(([glyph]) => !subset.has(glyph)) + .map(([glyph, file]) => `${glyph} (${file})`); + + assert.deepEqual( + missing, + [], + 'Glyphs named by the sources but absent from the index.html icon_names list. ' + + 'Add them there — an unlisted glyph renders as its own name on screen: ' + + missing.join(', '), + ); +}); + +test('the unused Material Icons Round family is not loaded', () => { + // A second icon font, 173 kB, for a family no source ever uses — and + // src/cockpitMarkup.test.mjs already asserts the markup must not use it. + const html = readFileSync(INDEX_HTML, 'utf8'); + assert.doesNotMatch( + html, + /Material\+Icons\+Round/, + 'index.html loads an icon font nothing renders', + ); +}); From 1c9a55e2f994829b869b99576506096addf82681 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:26:19 -0500 Subject: [PATCH 04/10] docs: drop two retired CCTV client flags from the env template (#503) Supersedes #283 by @dajiaohuang, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: dajiaohuang --- .env.example | 2 -- CHANGELOG.md | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1a8830ed0..e537dfb16 100644 --- a/.env.example +++ b/.env.example @@ -171,5 +171,3 @@ AISSTREAM_API_KEY= # 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 -# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag) -# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag) diff --git a/CHANGELOG.md b/CHANGELOG.md index a55642c7e..556c5349a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -328,6 +328,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md whoever adds the next destination; it passes on the current entries and makes no claim about how well any destination is framed. Contributed by daikaginza (#168). +- Drop `CCTV_AUTO_CALIBRATE` and `CCTV_DRAPE_MESH` from `.env.example`. Nothing + reads either name; the features they once switched no longer exist, so setting + them did nothing. Contributed by dajiaohuang (#283). - Extract application lifecycle and viewer exports. Split standalone startup into scene setup, controls, layer registration, tools and loading UI. Startup failure From b00f8353411708e053b9866010304d5a103a7a66 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:29:45 -0500 Subject: [PATCH 05/10] ci: pin GitHub Actions to commit SHAs (#505) Supersedes #309 by @SurefireStudios, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: SurefireStudios <123013554+SurefireStudios@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167ea65a8..8c1677b25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,12 @@ jobs: GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }} steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} cache: npm @@ -63,12 +63,12 @@ jobs: GEV_REQUIRE_ALLOCATION_GATE: '0' steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.0 cache: npm diff --git a/CHANGELOG.md b/CHANGELOG.md index 556c5349a..a5a229e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -229,6 +229,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md part-way through the picture, the upstream request is cancelled rather than left running, and neither case marks the camera degraded. Ordinary seeking is unaffected. Contributed by Maher-Reven (#253). +- CI pins `actions/checkout` and `actions/setup-node` to the commits their + `v4.4.0` tags name, so a repointed tag cannot change what runs in CI. The + version stays in a trailing comment, and moving to a later release is a + deliberate edit. Contributed by SurefireStudios (#309). - Validate configured Google Places coordinates and text queries before rate limiting or upstream requests; preserve the keyless capability response. From 70dca86bc11c483289ada854d0bbd16b87d87885 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:23 -0500 Subject: [PATCH 06/10] fix(pinokio): disclose incoming changes before Update pulls and installs (#507) Supersedes #356 by @Lob26, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: Pedro Lobato <69770518+Lob26@users.noreply.github.com> --- CHANGELOG.md | 10 + scripts/format-scope.json | 4 +- scripts/pinokio-update.mjs | 240 +++++++++++++++- src/pinokioUpdatePreview.test.mjs | 447 ++++++++++++++++++++++++++++++ 4 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 src/pinokioUpdatePreview.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a229e13..790aa2740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md last good picture kept after a failed refresh — and the one feature that sends imagery anywhere: the voice assistant's viewport screenshot. Contributed by Lob26 (#357). +- Pinokio's Update shows what it is about to install before it installs it: the + tracking branch, the remote it fetched from, the incoming commits and their + diffstat. The remote is printed as host and path — a password or token in the + URL's user field is replaced and any query string dropped, though a secret + spelled as an ordinary path segment cannot be told from a repository name. It + fetches once and applies exactly the revision it named, so a commit that lands + mid-update cannot be installed unannounced. A git read it cannot complete is + reported as such instead of as "already up to date", and if the revision to + apply cannot be resolved at all the update stops without installing anything + and exits unsuccessfully. Contributed by Lob26 (#356). - Extract panel disclosure and hover/focus controls into a reusable module; cancel their listeners and pending work during replacement and teardown. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7c192c15e..10a18fd63 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -615,5 +615,7 @@ "src/cityPresets.test.mjs", "server/providers/cctv/range.js", "src/data/cctvMediaRange.test.mjs", - "src/materialSymbolsSubset.test.mjs" + "src/materialSymbolsSubset.test.mjs", + "scripts/pinokio-update.mjs", + "src/pinokioUpdatePreview.test.mjs" ] diff --git a/scripts/pinokio-update.mjs b/scripts/pinokio-update.mjs index 22d19b886..1259179c7 100644 --- a/scripts/pinokio-update.mjs +++ b/scripts/pinokio-update.mjs @@ -1,5 +1,239 @@ #!/usr/bin/env node -import { installPinokioDependencies, runChecked } from './pinokio-install.mjs'; +/** + * Pinokio "Update": disclose what is about to land, then fast-forward and reinstall. + * + * Update is a fixed menu item aimed at people who only want to run the app, and + * it executes install scripts from whatever the configured remote serves. It + * cannot ask for confirmation — Pinokio drives it non-interactively — so the + * safety it can offer is disclosure: fetch once, print the remote it fetched + * from plus the incoming commits and their diffstat, and then apply that exact + * revision. Someone who sees an unfamiliar remote or an unexpected set of + * commits can close the window before any install script runs. + * + * @module scripts/pinokio-update + */ +import { realpathSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + installPinokioDependencies, + isDirectInvocation, + runChecked, +} from './pinokio-install.mjs'; -runChecked('git', ['pull', '--ff-only']); -installPinokioDependencies(); +const MODULE_PATH = fileURLToPath(import.meta.url); +const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..')); + +/** + * Read a git value. + * + * @param {string[]} args - git arguments. + * @param {object} [options] + * @param {string} [options.cwd] - Repository to read. + * @returns {string|null} Trimmed stdout, `''` when git succeeded with no + * output, or null when git failed (no upstream, detached HEAD, not a + * checkout). Callers must keep those two apart: an empty range and an + * unreadable one mean very different things to someone about to install. + */ +export function readGit(args, { cwd = ROOT } = {}) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.error || result.status !== 0) return null; + return result.stdout.trim(); +} + +/** + * Remove credentials from a remote URL before it is printed. + * + * A remote can carry a password or an access token in its userinfo + * (`https://token@host/org/repo.git`) or in a query parameter + * (`?access_token=…`). The point of printing the remote is to show WHERE the + * update comes from, which the host and path already say — so the userinfo is + * replaced and the query string and fragment are dropped whole, rather than + * matched against a list of parameter names that would miss the next one. + * + * A secret spelled as an ordinary path segment is not detected; nothing here + * can tell one of those from a repository name. + * + * @param {string|null|undefined} url - Remote URL as git reports it. + * @returns {string|null} Printable URL, or null when there is nothing to print. + */ +export function redactRemoteUrl(url) { + if (typeof url !== 'string' || url.length === 0) return null; + let parsed; + try { + parsed = new URL(url); + } catch { + // Not a URL: scp-style `git@host:org/repo.git`, or a local path. Neither + // has a userinfo field to hide. + return url; + } + const hadUserinfo = Boolean(parsed.username || parsed.password); + const hadQuery = Boolean(parsed.search || parsed.hash); + if (!hadUserinfo && !hadQuery) return url; + parsed.password = ''; + if (hadUserinfo) parsed.username = '***'; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); +} + +/** + * Print the remote, the commits about to be applied, and their diffstat. + * + * @param {object} [io] - Injection seam for tests. + * @param {string} [io.cwd] - Repository to inspect. + * @param {(line: string) => void} [io.log] - Normal output. + * @param {(line: string) => void} [io.warn] - Degraded-path output. + * @param {(remote: string) => void} [io.fetchRemote] - Fetch step; throws or + * exits on failure, because a report built on a stale remote ref describes + * the wrong changes. + * @param {(args: string[]) => (string|null)} [io.read] - git reader. + * @returns {{apply: string|null, fallback: boolean, stopped?: boolean}} + * `apply` is the exact revision that was disclosed and must now be applied. + * `fallback` asks the caller to run the plain pull so git itself reports + * whatever went wrong, and is only ever set before anything has been fetched. + * `stopped` means the update gave up: nothing can be shown, so nothing is + * applied. + */ +export function reportIncomingChanges(io = {}) { + const { + cwd = ROOT, + log = (line) => console.log(line), + warn = (line) => console.warn(line), + fetchRemote = (remote) => runChecked('git', ['fetch', '--quiet', remote]), + read = (args) => readGit(args, { cwd }), + } = io; + + const upstream = read([ + 'rev-parse', + '--abbrev-ref', + '--symbolic-full-name', + '@{u}', + ]); + // The full ref name is what later reads resolve, so a remote whose own name + // contains a slash (`team/origin`) cannot be mistaken for a path segment. + const upstreamRef = read(['rev-parse', '--symbolic-full-name', '@{u}']); + const branch = read(['rev-parse', '--abbrev-ref', 'HEAD']); + const remote = + branch && branch !== 'HEAD' + ? read(['config', '--get', `branch.${branch}.remote`]) + : null; + if (!upstream || !upstreamRef || !remote) { + warn('[update] No upstream branch is configured — cannot preview changes.'); + warn('[update] Continuing; `git pull --ff-only` will report the problem.'); + return { apply: null, fallback: true }; + } + + log(`[update] Tracking ${upstream}`); + if (remote === '.') { + // The upstream is another branch in this same checkout; there is nothing + // to fetch and no remote URL to disclose. + log('[update] Upstream is a local branch — nothing to fetch.'); + } else { + const url = redactRemoteUrl(read(['remote', 'get-url', remote])); + log(`[update] Fetching from ${url || remote}`); + // Fetch once, here. Everything below describes and then applies the + // revision this fetch brought in; a second fetch inside `git pull` could + // apply something newer than what was printed. + fetchRemote(remote); + } + + const target = read(['rev-parse', upstreamRef]); + const head = read(['rev-parse', 'HEAD']); + if (!target || !head) { + // Stop. Falling through to `git pull` here would fetch a second time and + // could install a revision this run never resolved, let alone disclosed. + warn(`[update] Could not resolve ${upstream} after fetching.`); + warn( + '[update] Stopping: nothing is applied, because nothing can be shown.', + ); + return { apply: null, fallback: false, stopped: true }; + } + if (target === head) { + log('[update] Already up to date — reinstalling dependencies only.'); + return { apply: null, fallback: false }; + } + + const commits = read([ + 'log', + '--oneline', + '--no-decorate', + `${head}..${target}`, + ]); + if (commits === null) { + // A read that failed is not an empty range. Saying "up to date" here would + // tell someone nothing is arriving while a revision is about to be applied. + warn(`[update] Could not list the commits in ${head}..${target}.`); + warn( + `[update] Applying ${target} anyway; it is the revision that was fetched.`, + ); + return { apply: target, fallback: false }; + } + + const lines = commits.length > 0 ? commits.split('\n') : []; + if (lines.length > 0) { + log(`\n[update] ${lines.length} incoming commit(s):`); + for (const line of lines) log(` ${line}`); + } else { + log( + `\n[update] ${upstream} is at ${target}, which is not ahead of this checkout.`, + ); + } + + const stat = read(['diff', '--stat', `${head}..${target}`]); + if (stat === null) { + warn(`[update] Could not read the diffstat for ${head}..${target}.`); + } else if (stat) { + log('\n[update] Files affected:'); + for (const line of stat.split('\n')) log(` ${line}`); + } + log(`\n[update] Applying ${target}, then reinstalling dependencies.\n`); + return { apply: target, fallback: false }; +} + +/** + * Disclose, then apply exactly what was disclosed. + * + * @param {object} [io] - Same seam as {@link reportIncomingChanges}, plus + * `apply`, which runs one git command and must fail loudly. + * @returns {{apply: string|null, fallback: boolean}} What the report decided. + */ +export function updateFromRemote(io = {}) { + const { apply = (args) => runChecked('git', args) } = io; + const plan = reportIncomingChanges(io); + // `merge --ff-only ` applies the object the report named. A second + // `pull` would fetch again and could land a different one. + if (plan.apply) apply(['merge', '--ff-only', plan.apply]); + else if (plan.fallback) apply(['pull', '--ff-only']); + return plan; +} + +/** + * What running this script does: disclose, apply, install. + * + * @param {object} [io] - The seam above, plus `install` and `fail`. + * @returns {{apply: string|null, fallback: boolean, stopped?: boolean}} The plan. + */ +export function runPinokioUpdate(io = {}) { + const { + install = installPinokioDependencies, + fail = (code) => process.exit(code), + } = io; + const plan = updateFromRemote(io); + if (plan.stopped) { + // Nothing could be shown, so nothing was applied. Reinstalling now would + // run install scripts under the banner of an update that did not happen, + // and report success for it. + fail(1); + return plan; + } + install(); + return plan; +} + +// Guarded like pinokio-start.mjs so the report above can be exercised without +// pulling and reinstalling as an import side effect. +if (isDirectInvocation(process.argv[1], MODULE_PATH)) { + runPinokioUpdate(); +} diff --git a/src/pinokioUpdatePreview.test.mjs b/src/pinokioUpdatePreview.test.mjs new file mode 100644 index 000000000..ca3cc7688 --- /dev/null +++ b/src/pinokioUpdatePreview.test.mjs @@ -0,0 +1,447 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + readGit, + redactRemoteUrl, + reportIncomingChanges, + runPinokioUpdate, + updateFromRemote, +} from '../scripts/pinokio-update.mjs'; + +const source = readFileSync( + new URL('../scripts/pinokio-update.mjs', import.meta.url), + 'utf8', +); + +/** Run git in a fixture repository, failing the test on a git error. */ +function git(cwd, args) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + assert.equal( + result.status, + 0, + `git ${args.join(' ')} failed: ${result.stderr || result.error}`, + ); + return result.stdout.trim(); +} + +/** Escape a path so it can be matched literally inside a RegExp. */ +const literal = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Build a disposable origin repository and a clone that tracks it. + * + * Everything is on disk and driven through real git, so the assertions are + * about what the update path does to a checkout rather than about a transcript + * of injected strings. + * + * @param {import('node:test').TestContext} t - Owns the fixture's cleanup. + */ +async function fixture(t) { + const scratch = await realpath( + await mkdtemp(path.join(tmpdir(), 'gev-update-')), + ); + t.after(() => rm(scratch, { recursive: true, force: true })); + const origin = path.join(scratch, 'origin'); + const clone = path.join(scratch, 'clone'); + + git(scratch, ['init', '--quiet', '--initial-branch=main', origin]); + git(origin, ['config', 'user.email', 'fixture@example.invalid']); + git(origin, ['config', 'user.name', 'Fixture']); + await writeFile(path.join(origin, 'README.md'), 'first\n'); + git(origin, ['add', 'README.md']); + git(origin, ['commit', '--quiet', '-m', 'first commit']); + + git(scratch, ['clone', '--quiet', origin, clone]); + git(clone, ['config', 'user.email', 'fixture@example.invalid']); + git(clone, ['config', 'user.name', 'Fixture']); + + /** Add one commit to the origin repository. */ + const push = async (message, contents) => { + await writeFile(path.join(origin, 'README.md'), contents); + git(origin, ['add', 'README.md']); + git(origin, ['commit', '--quiet', '-m', message]); + return git(origin, ['rev-parse', 'HEAD']); + }; + + /** Same, from a synchronous callback (used to move the remote mid-update). */ + const pushSync = (message, contents) => { + writeFileSync(path.join(origin, 'README.md'), contents); + git(origin, ['add', 'README.md']); + git(origin, ['commit', '--quiet', '-m', message]); + return git(origin, ['rev-parse', 'HEAD']); + }; + + const out = []; + const io = { + cwd: clone, + log: (line) => out.push(String(line)), + warn: (line) => out.push(String(line)), + fetchRemote: (remote) => git(clone, ['fetch', '--quiet', remote]), + apply: (args) => git(clone, args), + }; + + return { + scratch, + origin, + clone, + push, + pushSync, + io, + out, + text: () => out.join('\n'), + }; +} + +test('a checkout with no upstream is reported and left to the plain pull', async (t) => { + const app = await fixture(t); + git(app.clone, ['checkout', '--quiet', '-b', 'local-only']); + let fetched = null; + const plan = reportIncomingChanges({ + ...app.io, + fetchRemote: (remote) => { + fetched = remote; + }, + }); + assert.deepEqual(plan, { apply: null, fallback: true }); + assert.match(app.text(), /No upstream branch is configured/); + assert.equal(fetched, null, 'nothing is fetched without an upstream'); +}); + +test('the tracking branch and the remote it fetches from are printed', async (t) => { + const app = await fixture(t); + await app.push('second commit', 'second\n'); + reportIncomingChanges(app.io); + assert.match(app.text(), /Tracking origin\/main/); + assert.match(app.text(), new RegExp(`Fetching from ${literal(app.origin)}`)); +}); + +test('the incoming commits and their diffstat are printed, and the report alone applies nothing', async (t) => { + const app = await fixture(t); + const before = git(app.clone, ['rev-parse', 'HEAD']); + await app.push('second commit', 'second\n'); + await app.push('third commit', 'third\n'); + const plan = reportIncomingChanges(app.io); + const text = app.text(); + assert.match(text, /2 incoming commit\(s\)/); + assert.match(text, /second commit/); + assert.match(text, /third commit/); + assert.match(text, /README\.md \| 2 \+-/); + assert.equal(plan.fallback, false); + assert.equal( + git(app.clone, ['rev-parse', 'HEAD']), + before, + 'the report must not move the checkout', + ); +}); + +test('the revision that is applied is the revision that was disclosed', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + const plan = reportIncomingChanges(app.io); + // Something lands on the remote between the disclosure and the apply. A + // second fetch would install it without ever naming it. + await app.push('a commit nobody was shown', 'fourth\n'); + assert.equal(plan.apply, disclosed); + app.io.apply(['merge', '--ff-only', plan.apply]); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed); + assert.match(app.text(), new RegExp(`Applying ${disclosed}`)); +}); + +test('the whole update applies exactly one disclosed revision', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + const plan = updateFromRemote(app.io); + assert.equal(plan.apply, disclosed); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed); +}); + +test('a remote that moves during the update does not change what is applied', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + let later = null; + const plan = updateFromRemote({ + ...app.io, + fetchRemote: (remote) => { + git(app.clone, ['fetch', '--quiet', remote]); + // The remote gains a commit after the one fetch this update makes. Only + // a second fetch — which there must not be — could reach it. + later = app.pushSync('a commit nobody was shown', 'fourth\n'); + }, + }); + assert.equal(plan.apply, disclosed); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed); + assert.notEqual(git(app.clone, ['rev-parse', 'HEAD']), later); +}); + +test('an up-to-date checkout says so and applies nothing', async (t) => { + const app = await fixture(t); + const before = git(app.clone, ['rev-parse', 'HEAD']); + const plan = updateFromRemote(app.io); + assert.deepEqual(plan, { apply: null, fallback: false }); + assert.match(app.text(), /Already up to date/); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before); +}); + +test('an unreadable commit range is reported as unreadable, not as up to date', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + const plan = reportIncomingChanges({ + ...app.io, + read: (args) => + args[0] === 'log' ? null : readGit(args, { cwd: app.clone }), + }); + const text = app.text(); + assert.doesNotMatch(text, /Already up to date/); + assert.match(text, /Could not list the commits/); + assert.equal(plan.apply, disclosed, 'the fetched revision is still applied'); +}); + +test('an upstream that cannot be resolved after fetching stops the update', async (t) => { + const app = await fixture(t); + const before = git(app.clone, ['rev-parse', 'HEAD']); + await app.push('second commit', 'second\n'); + const plan = updateFromRemote({ + ...app.io, + // The target ref resolves to nothing after the fetch — a tracking ref + // pruned out from under the run. + read: (args) => + args[0] === 'rev-parse' && + args.length === 2 && + args[1].startsWith('refs/') + ? null + : readGit(args, { cwd: app.clone }), + }); + assert.deepEqual(plan, { apply: null, fallback: false, stopped: true }); + assert.doesNotMatch(app.text(), /Already up to date/); + assert.match(app.text(), /Could not resolve origin\/main after fetching/); + assert.match(app.text(), /Stopping/); + // Nothing may be applied: a fallback pull here would fetch again and could + // install a revision this run never resolved. + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before); +}); + +test('a remote whose name contains a slash is read correctly', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + git(app.clone, ['remote', 'rename', 'origin', 'team/origin']); + git(app.clone, ['fetch', '--quiet', 'team/origin']); + git(app.clone, ['branch', '--set-upstream-to=team/origin/main', 'main']); + const plan = updateFromRemote({ + ...app.io, + fetchRemote: (remote) => { + assert.equal(remote, 'team/origin'); + git(app.clone, ['fetch', '--quiet', remote]); + }, + }); + assert.equal(plan.apply, disclosed); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed); + assert.match(app.text(), new RegExp(`Fetching from ${literal(app.origin)}`)); +}); + +test('a diffstat that cannot be read is reported, and the commits still are', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + const plan = reportIncomingChanges({ + ...app.io, + read: (args) => + args[0] === 'diff' ? null : readGit(args, { cwd: app.clone }), + }); + assert.equal(plan.apply, disclosed); + assert.match(app.text(), /1 incoming commit\(s\)/); + assert.match(app.text(), /Could not read the diffstat/); + assert.doesNotMatch(app.text(), /Files affected/); +}); + +test('diverged history is refused rather than applied', async (t) => { + const app = await fixture(t); + await app.push('second commit', 'second\n'); + await writeFile(path.join(app.clone, 'README.md'), 'local work\n'); + git(app.clone, ['add', 'README.md']); + git(app.clone, ['commit', '--quiet', '-m', 'local commit']); + const before = git(app.clone, ['rev-parse', 'HEAD']); + const plan = reportIncomingChanges(app.io); + const refused = spawnSync('git', ['merge', '--ff-only', plan.apply], { + cwd: app.clone, + encoding: 'utf8', + }); + assert.notEqual(refused.status, 0, 'a fast-forward-only merge must refuse'); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before); +}); + +test('a remote that is behind this checkout is named, not announced as incoming', async (t) => { + const app = await fixture(t); + await writeFile(path.join(app.clone, 'README.md'), 'ahead\n'); + git(app.clone, ['add', 'README.md']); + git(app.clone, ['commit', '--quiet', '-m', 'local commit']); + const plan = reportIncomingChanges(app.io); + assert.match(app.text(), /is not ahead of this checkout/); + assert.doesNotMatch(app.text(), /incoming commit/); + assert.ok(plan.apply, 'the fetched revision is still named'); +}); + +test('a stop means nothing is installed, and the script fails', async (t) => { + const app = await fixture(t); + const before = git(app.clone, ['rev-parse', 'HEAD']); + await app.push('second commit', 'second\n'); + const installs = []; + const failures = []; + const plan = runPinokioUpdate({ + ...app.io, + read: (args) => + args[0] === 'rev-parse' && + args.length === 2 && + args[1].startsWith('refs/') + ? null + : readGit(args, { cwd: app.clone }), + install: () => installs.push('install'), + fail: (code) => failures.push(code), + }); + assert.equal(plan.stopped, true); + // Installing here would run install scripts and report success for an update + // that never happened. + assert.deepEqual(installs, []); + assert.deepEqual(failures, [1]); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), before); +}); + +test('an ordinary update installs once and does not fail', async (t) => { + const app = await fixture(t); + const disclosed = await app.push('second commit', 'second\n'); + const installs = []; + const failures = []; + const plan = runPinokioUpdate({ + ...app.io, + install: () => installs.push('install'), + fail: (code) => failures.push(code), + }); + assert.equal(plan.apply, disclosed); + assert.equal(git(app.clone, ['rev-parse', 'HEAD']), disclosed); + assert.deepEqual(installs, ['install']); + assert.deepEqual(failures, []); +}); + +test('an up-to-date checkout still reinstalls', async (t) => { + const app = await fixture(t); + const installs = []; + const plan = runPinokioUpdate({ + ...app.io, + install: () => installs.push('install'), + fail: () => assert.fail('an up-to-date checkout is not a failure'), + }); + assert.deepEqual(plan, { apply: null, fallback: false }); + assert.deepEqual(installs, ['install']); +}); + +test('the entrypoint is what the direct invocation runs', () => { + const guarded = source.slice(source.indexOf('if (isDirectInvocation(')); + assert.match(guarded, /runPinokioUpdate\(\);/); + // The install must not sit beside it in the guard, where a stop could not + // prevent it. + assert.doesNotMatch(guarded, /installPinokioDependencies\(\)/); +}); + +test('a password in the remote URL is not printed', () => { + assert.equal( + redactRemoteUrl('https://someone:ghp_secret@github.com/org/repo.git'), + 'https://***@github.com/org/repo.git', + ); +}); + +test('a bare token in the remote URL is not printed', () => { + const redacted = redactRemoteUrl( + 'https://ghp_secret@github.com/org/repo.git', + ); + assert.doesNotMatch(redacted, /ghp_secret/); + assert.match(redacted, /github\.com\/org\/repo\.git/); +}); + +test('a credential in a query parameter is not printed', () => { + assert.equal( + redactRemoteUrl('https://host.example/org/repo.git?access_token=secret'), + 'https://host.example/org/repo.git', + ); + // The whole query goes, so a parameter name nobody thought of goes with it. + assert.equal( + redactRemoteUrl('https://host.example/org/repo.git?sneaky=secret&x=1#frag'), + 'https://host.example/org/repo.git', + ); + assert.equal( + redactRemoteUrl('https://user:pw@host.example/org/repo.git?token=secret'), + 'https://***@host.example/org/repo.git', + ); +}); + +test('an ordinary remote URL is printed unchanged', () => { + const url = 'https://github.com/bilawalsidhu/gods-eye-view.git'; + assert.equal(redactRemoteUrl(url), url); + assert.equal( + redactRemoteUrl('git@github.com:bilawalsidhu/gods-eye-view.git'), + 'git@github.com:bilawalsidhu/gods-eye-view.git', + ); + assert.equal(redactRemoteUrl(null), null); + assert.equal(redactRemoteUrl(''), null); +}); + +test('a credential-bearing remote is redacted in the printed report', async (t) => { + const app = await fixture(t); + await app.push('second commit', 'second\n'); + reportIncomingChanges({ + ...app.io, + read: (args) => + args[0] === 'remote' + ? 'https://someone:ghp_secret@github.com/org/repo.git' + : readGit(args, { cwd: app.clone }), + }); + assert.doesNotMatch(app.text(), /ghp_secret/); + assert.match( + app.text(), + /Fetching from https:\/\/\*\*\*@github\.com\/org\/repo\.git/, + ); +}); + +test('a failed git read is null, and an empty successful read is an empty string', async (t) => { + const app = await fixture(t); + assert.equal( + readGit(['rev-parse', '--verify', 'refs/heads/absent'], { cwd: app.clone }), + null, + ); + assert.equal( + readGit(['log', '--oneline', 'HEAD..HEAD'], { cwd: app.clone }), + '', + ); +}); + +test('the update and reinstall stay behind the direct-invocation guard', () => { + // Without the guard, importing this module to test it would update the + // checkout and run npm ci as an import side effect. + assert.match( + source, + /if \(isDirectInvocation\(process\.argv\[1\], MODULE_PATH\)\) \{/, + ); + const entrypoint = source.slice( + source.indexOf('export function runPinokioUpdate'), + ); + // Order is the whole point of the fix: disclose and apply, then install. + assert.ok( + entrypoint.indexOf('updateFromRemote(io)') < + entrypoint.indexOf('install()'), + 'the update must be disclosed and applied before dependencies are installed', + ); +}); + +test('the disclosed revision is applied by merge, not by a second pull', () => { + const applier = source.slice( + source.indexOf('export function updateFromRemote'), + ); + assert.match(applier, /apply\(\['merge', '--ff-only', plan\.apply\]\)/); + // The plain pull remains, but only on the path where nothing was disclosed. + assert.match( + applier, + /else if \(plan\.fallback\) apply\(\['pull', '--ff-only'\]\)/, + ); +}); From 94f5e4dd4584e52d2c39df074abd9a898fc3f6fb Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:38:28 -0500 Subject: [PATCH 07/10] fix(windows): isolate the ACL verifier from the PowerShell 7 module path (#508) Supersedes #161 by @michaelhan1208, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: michaelhan1208 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 10 +++ src/keySetupHardening.mjs | 33 ++++++++- src/keySetupHardening.test.mjs | 126 +++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c1677b25..9ea5f599a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: run: npm run check:boundaries - name: Run focused onboarding tests - run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs + run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs - name: Build production bundle run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 790aa2740..6d29756ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md reported as such instead of as "already up to date", and if the revision to apply cannot be resolved at all the update stops without installing anything and exits unsuccessfully. Contributed by Lob26 (#356). +- On Windows, the credential-file hardening step verifies the file's permissions + through the system PowerShell. A side-by-side PowerShell 7 install prepends its + own module directories, which the 5.1 verifier cannot load, so the check failed + and the credential was refused. The verify script now sets its module path from + the running interpreter's own home, and the environment it is launched with + carries that one value and no differently cased alias of it. The tests that + cover it drive a stubbed process launcher, so what they check is the command + and environment the code builds; the Windows onboarding CI job now also runs + this file, where its one Windows-only case exercises the real hardener against + real native tools. Contributed by michaelhan1208 (#161). - Extract panel disclosure and hover/focus controls into a reusable module; cancel their listeners and pending work during replacement and teardown. diff --git a/src/keySetupHardening.mjs b/src/keySetupHardening.mjs index 86b77b7c8..64d807292 100644 --- a/src/keySetupHardening.mjs +++ b/src/keySetupHardening.mjs @@ -9,6 +9,12 @@ import { /** PowerShell verification for the exact owner-only Windows credential DACL. */ const WINDOWS_ACL_VERIFY_SCRIPT = [ "$ErrorActionPreference = 'Stop'", + // Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the + // interpreter that is actually running. $PSHOME is that interpreter's own + // physical directory, so this is correct even when the executable was named + // through the Sysnative bridge, and it cannot be steered by anything the + // parent environment set. + "$env:PSModulePath = Join-Path $PSHOME 'Modules'", '$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE', 'if (-not $acl.AreAccessRulesProtected) { exit 2 }', "$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')", @@ -143,15 +149,40 @@ export function hardenCredentialFile(filepath, { // accept only three explicit FullControl allow principals, with inheritance // disabled. Any unexpected rule, right, command error, or missing principal // fails closed before the secret reaches disk. + // + // The verify process must load Microsoft.PowerShell.Security (Get-Acl) + // from the Windows PowerShell system module tree ONLY. A side-by-side + // PowerShell 7 install prepends its own module trees to PSModulePath at + // startup; inherited into a 5.1 process, the incompatible 7.x manifest + // cannot be autoloaded and the verify step fails. The script itself sets + // the path from $PSHOME; this is the same value computed ahead of time, so + // nothing inherited is in force even for the moment before it runs. The + // Sysnative spelling is a 32-bit caller's bridge and not a directory the + // launched native process can read, so the physical name is used. + const powershellModuleDirectory = path.win32.join( + path.win32 + .dirname(tools.powershell) + .replace(/\\Sysnative\\/i, '\\System32\\'), + 'Modules', + ); + // Windows environment names are case-insensitive, and a child can end up + // carrying a differently cased alias alongside the value set here. Drop + // every spelling before setting the trusted one. + const verifyEnvironment = Object.fromEntries( + Object.entries(environment).filter( + ([name]) => name.toLowerCase() !== 'psmodulepath', + ), + ); const verified = spawn(tools.powershell, [ '-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_VERIFY_SCRIPT, ], { env: { - ...environment, + ...verifyEnvironment, GEV_ACL_FILE: filepath, GEV_ACL_USER_SID: sid, + PSModulePath: powershellModuleDirectory, }, stdio: 'ignore', windowsHide: true, diff --git a/src/keySetupHardening.test.mjs b/src/keySetupHardening.test.mjs index ec7bcdffc..1ea9b3a8c 100644 --- a/src/keySetupHardening.test.mjs +++ b/src/keySetupHardening.test.mjs @@ -126,6 +126,132 @@ test('Windows hardening applies and then verifies the exact restricted DACL', () assert.match(calls[2].args.at(-1), /seen\.Count -ne 3/); }); +test('Windows hardening isolates the verify PowerShell from a pwsh7-polluted PSModulePath', () => { + const calls = []; + // Exactly what a side-by-side PowerShell 7 install prepends at startup: + // its own user Documents\PowerShell, Program Files\PowerShell, and + // Program Files\PowerShell\7 module trees all shadow the 5.1 system path. + const pollutedModulePath = [ + 'C:\\Users\\alice\\Documents\\PowerShell\\Modules', + 'C:\\Program Files\\PowerShell\\Modules', + 'c:\\program files\\powershell\\7\\Modules', + 'C:\\Program Files\\WindowsPowerShell\\Modules', + 'C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules', + ].join(';'); + const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', { + platform: 'win32', + environment: { + SYSTEMROOT: WINDOWS_ROOT, + PSModulePath: pollutedModulePath, + }, + fileSystem: windowsFileSystem(), + spawn(command, args, options) { + calls.push({ command, args, options }); + if (command.endsWith('\\whoami.exe')) { + return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"\r\n` }; + } + return { status: 0, signal: null }; + }, + }); + assert.equal(result, true); + const verify = calls.find(({ command }) => command.endsWith('powershell.exe')); + assert.ok(verify, 'the ACL verify powershell must be spawned'); + const modulePath = verify.options.env.PSModulePath; + // Fail closed: the verification interpreter must never inherit pwsh7's + // module trees — a 7.x Microsoft.PowerShell.Security manifest cannot be + // autoloaded by 5.1, and an untrusted tree must not shadow Get-Acl at all. + assert.ok(!/Documents\\PowerShell\\Modules/i.test(modulePath), 'user pwsh7 module tree must be stripped'); + assert.ok(!/Program Files\\PowerShell\\Modules/i.test(modulePath), 'Program Files pwsh7 module tree must be stripped'); + assert.ok(!/Program Files\\PowerShell\\7\\Modules/i.test(modulePath), 'pwsh7 install module tree must be stripped'); + // ... but the 5.1 system module directory (where Get-Acl lives) must remain. + assert.match(modulePath, /System32\\WindowsPowerShell\\v1\.0\\Modules/i); +}); + +test('the verify script takes its module path from the running interpreter', () => { + const calls = []; + const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', { + platform: 'win32', + environment: { SYSTEMROOT: WINDOWS_ROOT }, + fileSystem: windowsFileSystem(), + spawn(command, args, options) { + calls.push({ command, args, options }); + if (command.endsWith('\\whoami.exe')) { + return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` }; + } + return { status: 0, signal: null }; + }, + }); + assert.equal(result, true); + const verify = calls.find(({ command }) => command.endsWith('powershell.exe')); + const script = verify.args.at(-1); + // $PSHOME is the interpreter's own physical directory, so this holds even + // where the executable was named through a bridge path. + assert.match(script, /\$env:PSModulePath = Join-Path \$PSHOME 'Modules'/); + // ...and it is the first thing the script does, before Get-Acl is resolved. + assert.ok( + script.indexOf('PSModulePath') < script.indexOf('Get-Acl'), + 'the module path must be set before Get-Acl is invoked', + ); +}); + +test('a 32-bit caller passes the physical module directory, not the Sysnative bridge', () => { + const calls = []; + const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', { + platform: 'win32', + architecture: 'ia32', + environment: { SYSTEMROOT: WINDOWS_ROOT }, + fileSystem: windowsFileSystem({ systemDirectory: 'Sysnative' }), + spawn(command, args, options) { + calls.push({ command, args, options }); + if (command.endsWith('\\whoami.exe')) { + return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` }; + } + return { status: 0, signal: null }; + }, + }); + assert.equal(result, true); + const verify = calls.find(({ command }) => command.endsWith('powershell.exe')); + // The executable is still named through the bridge the 32-bit caller needs. + assert.match(verify.command, /\\Sysnative\\/); + // The module directory is not: Sysnative is not a directory the launched + // native process can read. + assert.doesNotMatch(verify.options.env.PSModulePath, /Sysnative/i); + assert.match( + verify.options.env.PSModulePath, + /^C:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\Modules$/i, + ); +}); + +test('differently cased PSModulePath aliases do not survive into the verify process', () => { + const calls = []; + const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', { + platform: 'win32', + environment: { + SYSTEMROOT: WINDOWS_ROOT, + // Windows environment names are case-insensitive; a child can keep a + // differently cased alias alongside the value set for it. + PSMODULEPATH: 'C:\\Program Files\\PowerShell\\7\\Modules', + psmodulepath: 'C:\\Users\\alice\\Documents\\PowerShell\\Modules', + PsModulePath: 'C:\\attacker\\Modules', + }, + fileSystem: windowsFileSystem(), + spawn(command, args, options) { + calls.push({ command, args, options }); + if (command.endsWith('\\whoami.exe')) { + return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` }; + } + return { status: 0, signal: null }; + }, + }); + assert.equal(result, true); + const verify = calls.find(({ command }) => command.endsWith('powershell.exe')); + const aliases = Object.keys(verify.options.env).filter( + (name) => name.toLowerCase() === 'psmodulepath', + ); + assert.deepEqual(aliases, ['PSModulePath'], 'exactly one spelling may reach the child'); + assert.doesNotMatch(verify.options.env.PSModulePath, /PowerShell\\7|Documents|attacker/i); +}); + test('Windows hardening bypasses PATH-shadowed native ACL tools', () => { const commands = []; const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', { From a04502559e07c31dcec7245e89bddcb556c97be8 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:42:24 -0500 Subject: [PATCH 08/10] docs(cctv): correct the panel reset instructions to the current storage key (#509) Supersedes #408 by @vegettto, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: vegettto --- CHANGELOG.md | 9 ++ docs/CURRENT-STATE.md | 2 +- docs/KNOWN-ISSUES.md | 27 +++-- scripts/dev-fresh.sh | 4 +- scripts/format-scope.json | 1 + src/tooling/panelStorageDocs.test.mjs | 148 ++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 src/tooling/panelStorageDocs.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d29756ee..83fd11a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -273,6 +273,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md and environment the code builds; the Windows onboarding CI job now also runs this file, where its one Windows-only case exercises the real hardener against real native tools. Contributed by michaelhan1208 (#161). +- The panel-recovery instructions in `docs/KNOWN-ISSUES.md`, `docs/CURRENT-STATE.md` + and `scripts/dev-fresh.sh` describe what the interface does. The rails lay panels + out themselves and write no stored position, so a CCTV panel that looks missing + is collapsed or its layer is off; the collapsed-state key is what opens it, and + the value to store is `'0'`, since removing the key returns the panel to its + default, which is collapsed. A view opened from a share link is laid out from + the link and ignores the stored value, so the console workaround is for ordinary + loads only. A check keeps the documented keys and outcomes in step with the + code. Contributed by vegettto (#408). - Extract panel disclosure and hover/focus controls into a reusable module; cancel their listeners and pending work during replacement and teardown. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ba03ad08a..fb0339a95 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3305,7 +3305,7 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Draggable panel positions persist at `godsEyeView.v7.panelPos.` (collapsed states at `godsEyeView.v6.panelCollapsed.`). +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). - Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 33c665f39..92c92c1f9 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -30,21 +30,36 @@ Next iteration candidates: --- -### CCTV panel can appear "missing" after layout refactors +### CCTV panel can appear "missing" Status: Open (workaround available) Context: -- Panel positions are persisted in local storage and can restore off-screen after UI changes. +- The rails lay their panels out themselves. No panel is dragged into place at + startup and no stored position is read, so a panel that looks missing is + collapsed or its layer is off rather than parked off-screen. The CCTV panel + starts collapsed and stays that way until you open it or a camera activates. Workaround: -- In browser console: - - `localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel');` +- Check that the CCTV layer is enabled in the Layers panel, then open the panel + from its header control; it also opens on its own when a camera activates. +- To force it open on an ordinary load, store the expanded state and reload. In + the browser console: + - `localStorage.setItem('godsEyeView.v6.panelCollapsed.cctv-panel', '0');` + - `location.reload();` +- Removing that key instead returns the panel to its default, which is collapsed: - `localStorage.removeItem('godsEyeView.v6.panelCollapsed.cctv-panel');` - `location.reload();` +- Neither console line changes anything when the page was opened from a share + link: a shared view is laid out from the link, not from what this browser has + stored, so open the panel from its header control instead. Related keys (current versions): -- Panel positions: `godsEyeView.v7.panelPos.` (re-versioned 2026-06-10) -- Panel collapsed state: `godsEyeView.v6.panelCollapsed.` +- Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, + `'1'` closed, absent means the panel's own default. A view opened from a share + link ignores the stored value entirely. +- Panel positions: `godsEyeView.v8.panelPos.` — the versioned name for a + stored position. The current layout writes none, so deleting one changes + nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` --- diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 6ec989ed6..2f9454401 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -306,8 +306,8 @@ case "${HOST}" in esac echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}" echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)." -echo "If panels are still missing, run this once in browser console:" -echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();" +echo "The CCTV panel starts collapsed; open it from its header, or in browser console:" +echo "localStorage.setItem('godsEyeView.v6.panelCollapsed.cctv-panel', '0'); location.reload();" echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}" if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 10a18fd63..5813b3a58 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -10,6 +10,7 @@ "src/tooling/fixtureRoot.mjs", "src/tooling/fixtureRoot.test.mjs", "src/tooling/format.test.mjs", + "src/tooling/panelStorageDocs.test.mjs", "src/tooling/packageBoundaries.test.mjs", "src/data/infrastructure.js", "src/data/infrastructure.test.mjs", diff --git a/src/tooling/panelStorageDocs.test.mjs b/src/tooling/panelStorageDocs.test.mjs new file mode 100644 index 000000000..5af18b96c --- /dev/null +++ b/src/tooling/panelStorageDocs.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); + +// Documentation consistency only: that every storage key a document spells out +// is the key the code writes. Whether deleting one has the effect the document +// claims is not checked here. +const DOCUMENTED_KEYS = [ + ['docs/KNOWN-ISSUES.md', 'godsEyeView.{layout}.panelCollapsed.cctv-panel'], + ['docs/KNOWN-ISSUES.md', 'godsEyeView.{layout}.panelCollapsed.'], + ['docs/KNOWN-ISSUES.md', 'godsEyeView.{position}.panelPos.'], + ['scripts/dev-fresh.sh', 'godsEyeView.{layout}.panelCollapsed.cctv-panel'], + ['docs/CURRENT-STATE.md', 'godsEyeView.{position}.panelPos.'], + ['docs/CURRENT-STATE.md', 'godsEyeView.{position}.panelPos.'], + ['docs/CURRENT-STATE.md', 'godsEyeView.{layout}.panelCollapsed.'], +]; + +test('documented panel storage keys are the keys the code writes', async () => { + const source = await readFile( + path.join(root, 'src/ui/panelPositionControls.js'), + 'utf8', + ); + const version = (name) => { + const [, found] = + source.match(new RegExp(`const ${name} = '([^']+)';`)) || []; + assert.ok(found, `panelPositionControls.js must declare ${name}`); + return found; + }; + const position = version('PANEL_POSITION_STORAGE_VERSION'); + const layout = version('PANEL_LAYOUT_STORAGE_VERSION'); + + const seen = new Map(); + for (const [file, shape] of DOCUMENTED_KEYS) { + if (!seen.has(file)) + seen.set(file, await readFile(path.join(root, file), 'utf8')); + const expected = shape + .replace('{position}', position) + .replace('{layout}', layout); + assert.ok( + seen.get(file).includes(expected), + `${file} must use ${expected}`, + ); + } + + // A document that still names a superseded version sends the reader to a key + // nothing writes. + for (const [file, content] of seen) { + const stalePositions = [ + ...content.matchAll(/godsEyeView\.(v\d+)\.panelPos/g), + ] + .map(([, found]) => found) + .filter((found) => found !== position); + assert.deepEqual( + stalePositions, + [], + `${file} names superseded position keys`, + ); + const staleCollapsed = [ + ...content.matchAll(/godsEyeView\.(v\d+)\.panelCollapsed/g), + ] + .map(([, found]) => found) + .filter((found) => found !== layout); + assert.deepEqual( + staleCollapsed, + [], + `${file} names superseded collapsed-state keys`, + ); + } +}); + +test('the documented outcomes hold: default, stored open, stored shut, and a shared view', async () => { + // The instructions promise two different outcomes; both are exercised here + // against the code that decides them. + const { PanelPositionControls } = + await import('../ui/panelPositionControls.js'); + const html = await readFile( + path.join(root, 'src/ui/templates/layer-panels.html'), + 'utf8', + ); + assert.match( + html, + / +
+ +
+ Shape +
+ + + +
+
+
+ + + + +
+