Merge branch 'main' into upstream/traffic-layer-stale

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-25 19:44:48 -07:00
commit 64c2af6758
27 files changed
+458 -20

No files matched your search

+5
View File
@@ -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:'+(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.
+6 -2
View File
@@ -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
@@ -207,6 +209,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)).
</details>
+5
View File
@@ -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.
+33 -1
View File
@@ -1,9 +1,29 @@
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 `<root>/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(/\/$/, '');
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.
* No inline script is permitted, and the only foreign script origins are the
@@ -165,7 +185,19 @@ 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/**',
// 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_GLOB}/dist/**`,
],
},
// These headers protect the document containing Provider Settings and
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
+3
View File
@@ -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)
@@ -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).
+3
View File
@@ -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).
+3
View File
@@ -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).
+4
View File
@@ -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).
+2
View File
@@ -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).
+4
View File
@@ -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).
+4 -1
View File
@@ -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
+40 -1
View File
@@ -1,5 +1,15 @@
# 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.
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
@@ -296,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
@@ -1839,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
@@ -5375,11 +5395,21 @@ 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:
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)
@@ -6231,3 +6261,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.
+3 -1
View File
@@ -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' },
];
}
+2 -1
View File
@@ -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"
]
@@ -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,
};
}
+2
View File
@@ -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(),
],
+1
View File
@@ -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');
});
+6 -5
View File
@@ -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';
+106
View File
@@ -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;
}
});
+19 -2
View File
@@ -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();
+45 -1
View File
@@ -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 });
+1
View File
@@ -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;
+9
View File
@@ -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]);
});
+3
View File
@@ -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 }));
};
@@ -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');
},
);
});
+25 -5
View File
@@ -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';
@@ -5,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 } 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';
@@ -30,6 +35,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'];
@@ -98,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,
@@ -112,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');