From 2379d20616a988924924e0500079de7b3dc9966b Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:01:48 -0500 Subject: [PATCH 01/15] Harden Vite fs deny for local logs and cache --- build/vite.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 4f2703e88..00479572d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -94,7 +94,16 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + '**/dist/**', + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). From ccb7b7333d3e13f0bda8fdea45706e49a37ca705 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:55:37 -0500 Subject: [PATCH 02/15] fix: scope Vite dist deny to repository root --- build/vite.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 00479572d..f390e359e 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,23 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -102,7 +116,10 @@ export function createBrowserViteConfig({ '**/ENVIRONMENT', '**/.gev-logs/**', '**/.gev-cache/**', - '**/dist/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 509ab57e4b1136232198e7bbac8f43cb09194061 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:27 -0500 Subject: [PATCH 03/15] fix: escape repo root in Vite deny glob --- build/vite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index f390e359e..05d8e5fa6 100644 --- a/build/vite.js +++ b/build/vite.js @@ -119,7 +119,7 @@ export function createBrowserViteConfig({ // Root-anchored: a bare '**/dist/**' would also deny every // dependency's own dist/ (e.g. node_modules/vite/dist/client), // breaking /@vite/client and other dev-client assets. - `${REPO_ROOT}/dist/**`, + `${REPO_ROOT_GLOB}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 0c1bb5a71b680fca09ff19fd133c81e18f4a51c1 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:35 -0500 Subject: [PATCH 04/15] docs: note Vite fs deny hardening ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .git/COMMIT_EDITMSG [unix] (16:23 16/10/2026) 1,1 All"/d/AI-Lab/Repos/gods-eye-view/.git/COMMIT_EDITMSG" [unix] 19L, 662B docs: note Vite fs deny hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d32365f..2c7445ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,6 +445,7 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency `dist/` directories, and checkout paths containing glob metacharacters are escaped before building the root-anchored pattern. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its From 1b641fde9f34462d508fc4520d4de21c642f8516 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:55:48 -0500 Subject: [PATCH 05/15] fix: restore escaped repo root after rebase --- build/vite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/vite.js b/build/vite.js index 05d8e5fa6..042b8e98d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,6 +18,8 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); +const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the From e3e4fc7284fcc59716f888bee6cc64dd07089ac5 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:55:56 -0500 Subject: [PATCH 06/15] test: cover Vite fs deny hardening --- CHANGELOG.md | 1 - build/vite.js | 6 +++++- changelog.d/948-vite-fs-deny.md | 4 ++++ src/tooling/viteBuild.test.mjs | 15 ++++++++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 changelog.d/948-vite-fs-deny.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7445ce6..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,7 +445,6 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. -- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency `dist/` directories, and checkout paths containing glob metacharacters are escaped before building the root-anchored pattern. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/build/vite.js b/build/vite.js index 042b8e98d..a541eb9aa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,7 +18,11 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); -const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); /** * Content-Security-Policy for every document the dev/preview server serves. diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 9e38a0295..9f309c48d 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; @@ -21,6 +25,15 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + assert.ok( + config.server.fs.deny.some((pattern) => pattern.endsWith('/dist/**')), + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/gods-eye-view (1)'), + 'C:/Users/a/gods-eye-view \\(1\\)', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; From f81ef406f7cafdb1a03ecb2a28aa19ca760cd23b Mon Sep 17 00:00:00 2001 From: John Rogers Date: Sun, 25 Oct 2026 14:08:14 -0500 Subject: [PATCH 07/15] =?UTF-8?q?(=E3=81=A3=E2=96=80=C2=AF=E2=96=80)?= =?UTF-8?q?=E3=81=A4=20--=20Issue=20#1240:=20added=20docker=20health=20che?= =?UTF-8?q?cks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index d34f4887a..4628279af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. From 3a28dcab0775388af2c42ae1dbd8fdb8c1d1f5e8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 17:32:15 -0700 Subject: [PATCH 08/15] fix(docker): align health probe port parsing and document health status --- Dockerfile | 2 +- changelog.d/1394-docker-healthcheck.md | 2 ++ docs/CURRENT-STATE.md | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1394-docker-healthcheck.md diff --git a/Dockerfile b/Dockerfile index 4628279af..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ EXPOSE 4173 # always on the Host allowlist, and the generous start period covers Vite's # first-run dependency optimisation. HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md new file mode 100644 index 000000000..75ead9667 --- /dev/null +++ b/changelog.d/1394-docker-healthcheck.md @@ -0,0 +1,2 @@ +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..63d523c28 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6228,3 +6228,12 @@ when their footprints touch or contain one another, never across a seam toleranc - `scripts/qa-form-fields.mjs` exercises native selections, number validity, calibration commit/cancel and stale-camera/disposal guards against an installed controller with fixture ports, plus default/Cyber and narrow translated layouts. + +## Docker health reporting + +The Docker image probes its local HTTP root with Node every 30 seconds, using +the launcher's parsed `PORT` (default 4173). A successful HTTP response marks +the container healthy. Checks time out after 10 seconds, with a 30-second +startup grace period and three consecutive failures before reporting unhealthy. +This reports app HTTP availability, not individual provider health, and does not +configure automatic restarts. From 2a17491c8bd5afe0e7cf01ac9137361941329713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1l=20G=C3=BCven=C3=A7?= Date: Mon, 26 Oct 2026 03:54:16 +0300 Subject: [PATCH 09/15] fix(docker): let SELinux hosts read the bind-mounted checkout (#1391) Allow the Docker checkout bind mount on SELinux hosts. Contribution by @anilguvenc. --- README.md | 2 ++ changelog.d/1391-docker-selinux.md | 4 ++++ compose.yaml | 5 ++++- docs/CURRENT-STATE.md | 4 ++++ src/dockerLauncher.test.mjs | 1 + 5 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1391-docker-selinux.md diff --git a/README.md b/README.md index 80f052571..ca90bf2c0 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md new file mode 100644 index 000000000..186147a2e --- /dev/null +++ b/changelog.d/1391-docker-selinux.md @@ -0,0 +1,4 @@ +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). diff --git a/compose.yaml b/compose.yaml index c2d3bf3d4..38cf438ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,5 +22,8 @@ services: # UI land in the .env on your host instead of vanishing with the # container. node_modules stays in the image: sharp and friends are # compiled for Linux and must not be shadowed by a host install. - - .:/app + # `:z` relabels the checkout for containers on SELinux hosts (Fedora, + # RHEL); without it the container cannot read /app at all and Node + # reports the launcher as "Cannot find module". Elsewhere it is ignored. + - .:/app:z - /app/node_modules diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 63d523c28..98214e541 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +Docker Compose mounts the checkout with the shared SELinux `:z` label so +containers can read it on enforcing SELinux hosts. The separate image-owned +`node_modules` volume and loopback-only port publishing remain unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index 213fe59dc..4e97849c0 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -75,4 +75,5 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); + assert.match(compose, /^\s+- \.:\/app:z$/m, 'SELinux hosts can read the bind-mounted checkout'); }); From a15fdd31340872a32e84453d1e59b48eb519b720 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:59:09 -0400 Subject: [PATCH 10/15] fix(hud): carry rounded DMS seconds into the minutes (#1339) Carry rounded HUD coordinate seconds into minutes and degrees. Contribution by @rudycelekli. --- changelog.d/1339-hud-dms-carry.md | 3 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- src/hud.js | 11 ++-- src/hudDms.test.mjs | 106 ++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1339-hud-dms-carry.md create mode 100644 src/hudDms.test.mjs diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 98214e541..bfbfcca90 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,12 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so containers can read it on enforcing SELinux hosts. The separate image-owned `node_modules` volume and loopback-only port publishing remain unchanged. +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d62e8bb47..20e28122a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1087,5 +1087,6 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", - "src/voice/globeResetConfirmation.test.mjs" + "src/voice/globeResetConfirmation.test.mjs", + "src/hudDms.test.mjs" ] diff --git a/src/hud.js b/src/hud.js index cf7983da7..1428f8503 100644 --- a/src/hud.js +++ b/src/hud.js @@ -574,11 +574,12 @@ export class IntelHUD { * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. */ _toDMS(decimal, type) { - const abs = Math.abs(decimal); - const deg = Math.floor(abs); - const minFloat = (abs - deg) * 60; - const min = Math.floor(minFloat); - const sec = ((minFloat - min) * 60).toFixed(2); + // Round once, in hundredths of a second, so a value that rounds up to + // 60.00" carries into the minutes and degrees instead of printing 60. + const hundredths = Math.round(Math.abs(decimal) * 360000); + const deg = Math.floor(hundredths / 360000); + const min = Math.floor(hundredths / 6000) % 60; + const sec = ((hundredths % 6000) / 100).toFixed(2); let dir; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs new file mode 100644 index 000000000..064906b5f --- /dev/null +++ b/src/hudDms.test.mjs @@ -0,0 +1,106 @@ +// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to +// two decimals, so a value within 0.005" of the next whole minute rounds up. +// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a +// coordinate. Ordinary decimal inputs land there through binary floating +// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00". +// +// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM +// loader cannot see, so this installs the same one-specifier stub as +// hudAltitudeDatum.test.mjs before importing it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type); + +test('a rounded-up second carries into the minutes', () => { + assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`); + assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`); + assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`); +}); + +test('a carry through 59 minutes reaches the degrees', () => { + assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`); + assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`); + assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`); +}); + +test('ordinary values keep their existing format', () => { + assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`); + assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`); + assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`); + assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`); + assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`); + assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`); +}); + +test('the telemetry tick paints the carried value into both readouts', () => { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { + latitude: (37.75 * Math.PI) / 180, + longitude: (-122.35 * Math.PI) / 180, + height: 1500, + }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + let hud; + try { + hud = new IntelHUD(viewer); + hud._updateCameraData(); + const latlon = elements.get('hud-latlon').textContent; + const bottom = elements.get('hud-bottom-line').textContent; + assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`); + assert.match(bottom, /LON: 122°21'00\.00"W/); + assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/); + } finally { + hud?.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); From 0dce2f8d04dc2048cf080d2e8894596f2ff5976a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 18:47:57 -0700 Subject: [PATCH 11/15] fix: complete PR #948 integration and documentation --- SECURITY.md | 5 +++++ docs/CURRENT-STATE.md | 6 ++++++ src/tooling/viteBuild.test.mjs | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 1baad029f..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfbfcca90..da8203264 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5385,6 +5385,12 @@ pins and share links already supply complete boxes. ## Auth + Launch +Vite's development filesystem deny list excludes local `.gev-logs/` and +`.gev-cache/` contents and the package checkout's root `dist/`, while allowing +dependency `dist/` assets. Checkout glob characters are escaped. This does not +apply to `public/` assets or built-preview serving. + + - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 2ca929176..075363e83 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -30,6 +31,20 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + const root = fileURLToPath(new URL('../../', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + assert.deepEqual( + config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')), + [`${escapeFsDenyPath(root)}/dist/**`], + 'only project build output is denied, not dependency dist directories', + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'), + 'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; From 1663733b58a33bfe1cdd9b233879ccebdcd9445e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 18:49:31 -0700 Subject: [PATCH 12/15] style: format Vite deny-list test imports --- src/tooling/viteBuild.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 075363e83..2a4988e30 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -6,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises'; import { request } from 'node:http'; import path from 'node:path'; import { build, createServer, preview } from 'vite'; -import { BROWSER_CSP, createBrowserViteConfig, escapeFsDenyPath } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js'; From 3a724f055abece7a33005363b60eac183a5b35ee Mon Sep 17 00:00:00 2001 From: NukeThemAII <125676612+NukeThemAII@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:32 +0700 Subject: [PATCH 13/15] fix(pinokio): open the app in the system browser (#1222) Open the Pinokio app in the system browser while retaining application framing protection. Contribution by @NukeThemAII. --- README.md | 6 ++++-- changelog.d/1222-pinokio-open-in-browser.md | 3 +++ docs/CURRENT-STATE.md | 6 +++++- pinokio/pinokio.js | 4 +++- src/pinokioLauncherContract.test.mjs | 3 +++ 5 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1222-pinokio-open-in-browser.md diff --git a/README.md b/README.md index ca90bf2c0..0a7509efa 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md new file mode 100644 index 000000000..bc6b62402 --- /dev/null +++ b/changelog.d/1222-pinokio-open-in-browser.md @@ -0,0 +1,3 @@ +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfbfcca90..439b29284 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5389,7 +5389,11 @@ pins and share links already supply complete boxes. - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: an absent marker exposes Install, a present marker exposes Start, and a - running server with a captured ready URL exposes Open God's Eye View. + running server with a captured ready URL exposes Open God's Eye View. That + default item uses Pinokio's `popout` option to open the system browser; the + Server item retains the launcher console. The app's framing headers stay + unchanged. This does not assume all Pinokio desktop versions enforce those + headers in their embedded view. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) diff --git a/pinokio/pinokio.js b/pinokio/pinokio.js index d781c1a58..49c42a361 100644 --- a/pinokio/pinokio.js +++ b/pinokio/pinokio.js @@ -22,8 +22,10 @@ module.exports = { if (starting) { const local = info.local('start.js'); if (local?.url) { + // Open in the system browser without changing the app's framing policy. + // This also avoids relying on Pinokio's embedded browser behavior. return [ - { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url }, + { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url, popout: true }, { icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' }, ]; } diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..78748ff32 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle runtime.url = url; const items = await menu(kernel, info); assert.equal(items.filter((item) => item.default).length, 1); + const ready = installed && running === 'start.js' && Boolean(url); + assert.equal(items[0].popout, ready ? true : undefined); + assert.ok(items.slice(1).every((item) => item.popout === undefined)); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault })); }; From 06c51d3438cbcdc0367220ae997b62583a8b0527 Mon Sep 17 00:00:00 2001 From: nu-stats <113466924+nu-stats@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:07:12 -0400 Subject: [PATCH 14/15] perf(weather): wake a staged frame on a 150 ms timer, not every render (#1345) Wake staged weather imagery on a bounded timer instead of requesting every render. Contribution by @nu-stats. --- changelog.d/1345-weather-staging-wake.md | 3 ++ docs/CURRENT-STATE.md | 4 +++ src/layers/weather/rendering.js | 21 +++++++++-- src/layers/weather/weather.test.mjs | 46 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1345-weather-staging-wake.md diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b09cef331..4b9e306ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -306,6 +306,10 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3; // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. const MAX_MOSAICS = 6; const MAX_PREFETCH_TILES = 8; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 486d42520..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now }); From a7fcb3c2322fbffdbc24b81f98b45ef9fafe8339 Mon Sep 17 00:00:00 2001 From: Jenil Patel Date: Sun, 25 Oct 2026 21:13:38 -0500 Subject: [PATCH 15/15] fix(maps): avoid 404 on keyless Google tiles-token probe (#1066) Return an uncached unavailable-token response for keyless Google tile-token requests. Contribution by @dvilrgamerz. --- changelog.d/1066-keyless-google-token.md | 3 + docs/CURRENT-STATE.md | 6 ++ server/standalone/google-token-unavailable.js | 27 ++++++ server/standalone/vite.config.js | 2 + src/maps/googleTokens.js | 1 + src/maps/googleTokens.test.mjs | 9 ++ src/tooling/googleTokenUnavailable.test.mjs | 94 +++++++++++++++++++ src/tooling/viteBuild.test.mjs | 9 +- 8 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1066-keyless-google-token.md create mode 100644 server/standalone/google-token-unavailable.js create mode 100644 src/tooling/googleTokenUnavailable.test.mjs diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b9e306ea..0ace86811 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1853,6 +1853,12 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. +In standalone development and preview servers, a missing Google tile-token +provider returns an uncached `204 No Content` for `GET /api/google/tiles-token` +(and HEAD) instead of a browser-visible 404. The fallback is registered after +real token providers and before the unknown-API handler, so existing Google +browser keys, provider-issued tokens, and unrelated API routes remain unchanged. + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffe5124ce..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 884c5b199..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,6 +38,7 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); + if (response.status === 204) return null; if (!response.ok) { await response.body?.cancel(); return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index d8a1e84ed..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/tooling/googleTokenUnavailable.test.mjs b/src/tooling/googleTokenUnavailable.test.mjs new file mode 100644 index 000000000..e0ee19232 --- /dev/null +++ b/src/tooling/googleTokenUnavailable.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js'; + +async function withServer({ beforeFallback } = {}, check) { + const middlewares = []; + const app = { + middlewares: { + use: (handler) => middlewares.push(handler), + }, + }; + if (beforeFallback) app.middlewares.use(beforeFallback); + googleTokenUnavailablePlugin().configureServer(app); + app.middlewares.use((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + + const server = createServer((req, res) => { + let index = 0; + const next = () => middlewares[index++]?.(req, res, next); + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const url = 'http://127.0.0.1:' + address.port; + try { + await check(url); + } finally { + server.close(); + await once(server, 'close'); + } +} + +test('keyless GET and HEAD return uncached 204 instead of 404', async () => { + await withServer({}, async (url) => { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url + '/api/google/tiles-token?probe=1', { + method, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await response.text(), ''); + } + }); +}); + +test('the existing client treats unavailable tokens as null', async () => { + await withServer({}, async (url) => { + const source = createGoogleTokenSource({ + fetchImpl: (path, options) => fetch(new URL(path, url), options), + }); + assert.equal(await source.token(), null); + }); +}); + +test('unrelated API paths and unsupported methods remain 404', async () => { + await withServer({}, async (url) => { + for (const [path, method] of [ + ['/api/google/tiles-token/extra', 'GET'], + ['/api/not-a-token', 'GET'], + ['/api/google/tiles-token', 'POST'], + ]) { + const response = await fetch(new URL(path, url), { method }); + assert.equal(response.status, 404); + } + }); +}); + +test('a real token provider registered first still wins', async () => { + await withServer( + { + beforeFallback: (req, res, next) => { + if (req.url !== '/api/google/tiles-token') return next(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + accessToken: 'server-token', + expiresAt: 10000000000000, + }), + ); + }, + }, + async (url) => { + const response = await fetch(url + '/api/google/tiles-token'); + assert.equal(response.status, 200); + assert.equal((await response.json()).accessToken, 'server-token'); + }, + ); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 2a4988e30..a9e8c5398 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -117,7 +117,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -5) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -131,9 +131,10 @@ test('root config retains existing named exports and standalone provider order', standaloneProviders.filter((name) => name !== 'gemini-live-proxy'), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); - // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-5).name, 'local-mcp'); + assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + // Token fallback follows real providers and local MCP before the API 404. + assert.equal(config.plugins.at(-6).name, 'local-mcp'); + assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');