mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
release: v0.1.0 — one-click install, keyless boot, in-app Provider Settings (#142)
The first formal release. - One-click install via Pinokio; keyless boot lands on a live Esri World Imagery satellite globe with keyless terrain, with automatic OSM fallback and graceful degradation when terrain is unavailable. - Provider Settings (the POWER UP panel): add, replace, or remove API keys inside the app; credential files made owner-only before any secret is written; external keys shown read-only; the panel refuses shared or proxied servers. - Keyless capability responses for the optional HUD summary and place search. - Aircraft-identity voice answers cover operator, type, and route, and say so plainly when enrichment is unavailable. - README rewritten keyless-first; concise 0.1.0 changelog section; CI workflow included. Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression 108/108, map-source tray QA pass, setup doctor ready.
This commit is contained in:
1 parent
6d83bb6008
commit
b6da93b8ac
170 files changed
+5800
-853
No files matched your search
@@ -163,7 +163,7 @@ export function createAnnotationEngine({
|
||||
* reach — and the next annotate of the same geometry would then stack a fresh
|
||||
* mark over the orphan. remove() tolerates partial and absent state, so this
|
||||
* is safe to call unconditionally; it must never mask the original failure.
|
||||
* (second review)
|
||||
* (review round 2)
|
||||
* @param {object} anno
|
||||
* @returns {void}
|
||||
*/
|
||||
@@ -1027,7 +1027,7 @@ function pendingAnimation(anno, now) {
|
||||
// Region-scale viewports (a mountain range, sea, or desert can span thousands of km)
|
||||
// must not launch the assist flight to space: frameAnnotation flies at range × 2.4,
|
||||
// so this cap keeps the camera at ≈290 km — the same regional swath scale the
|
||||
// fly_to_location natural-region heuristic uses (field test 2026-07-23).
|
||||
// fly_to_location natural-region heuristic uses (owner field test 2026-07-23).
|
||||
const VIEWPORT_ASSIST_RANGE_CAP_M = 120000;
|
||||
|
||||
/** flyTo range from a Places viewport box (low/high lat-lng corners), or null. */
|
||||
|
||||
@@ -575,7 +575,7 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Rollback must also unwind PARTIAL renderer state (second review) ──────────
|
||||
// ── Rollback must also unwind PARTIAL renderer state (review round 2) ──────────
|
||||
//
|
||||
// The harness above throws on the FIRST statement of add(), so a rollback that
|
||||
// only deletes the engine's map entry looked complete. The real renderers build
|
||||
|
||||
@@ -270,7 +270,7 @@ export async function resolveAnnotationTarget({
|
||||
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
|
||||
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
|
||||
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
|
||||
// sharing locality words must not.
|
||||
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
|
||||
const pointLike = isPointLikeTarget(target, entityKind, placeTypes, labelHint);
|
||||
// Grounds/compound asks (target OR label wording, or entityKind fact) go outline-first:
|
||||
// they reach the real enclosing-polygon sweep even under `around_the_thing` phrasing.
|
||||
@@ -306,7 +306,7 @@ export async function resolveAnnotationTarget({
|
||||
// BYPASSES the compound/building scope caps and the centroid drift bound
|
||||
// below: the region IS the asked scope ("outline the Alps"), the 60 km²
|
||||
// compound cap is for campuses (the meadow bug,
|
||||
// the resolver's verified live-data contract), and a continental ring's
|
||||
// docs/voice-engine-evaluation-2026-07-23.md §3), and a continental ring's
|
||||
// centroid legitimately sits far from any anchor. Admin and street scopes
|
||||
// are excluded — "Texas" must keep resolving as an admin boundary.
|
||||
if (!isAdmin && scope !== 'street' && !around) {
|
||||
@@ -343,7 +343,7 @@ export async function resolveAnnotationTarget({
|
||||
// FIRST: a bundled neighborhood polygon (reliable, deterministic, OFFLINE — no live
|
||||
// Overpass). Covered neighborhoods (e.g. SF: Chinatown/Marina/Mission/Presidio)
|
||||
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
|
||||
// path that times out and falls back to points.
|
||||
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
|
||||
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
|
||||
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
|
||||
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
|
||||
@@ -400,7 +400,7 @@ export async function resolveAnnotationTarget({
|
||||
// primary footprint above returns the BUILDING (the dome) or null — neither is the grounds. The
|
||||
// real enclosing polygon (e.g. "Capitol Square", leisure=park) IS in OSM but only surfaces via a
|
||||
// radius sweep for NAMED non-building polygons, taking the SMALLEST that geometrically contains
|
||||
// the point. So when a grounds-like
|
||||
// the point (research docs/compound-containment-research.md §1.4–1.5). So when a grounds-like
|
||||
// query produced a building or no polygon, prefer that REAL enclosing outline; fall to a
|
||||
// synthesized disc only when OSM DEFINITIVELY has none.
|
||||
if (groundsLike && (fp === null || fp?.kind === 'building')) {
|
||||
@@ -522,7 +522,7 @@ const MIN_DRIFT_FLOOR_KM = 50;
|
||||
// cover a large compound's monuments seen from an oblique view (Text Search is biased to 6 km here).
|
||||
const PLACES_MAX_DISTANCE_M = 8000;
|
||||
|
||||
// Synthesis radii (m) for cases where OSM has only
|
||||
// Synthesis radii (m) per osm-place-resolution-research.md §8.5. Used when OSM has only
|
||||
// a label point (most US neighborhoods) or the user asks for the area AROUND a landmark.
|
||||
const NEIGHBORHOOD_RADIUS_M = 750; // urban-neighborhood blob (600–900 m band)
|
||||
const AROUND_LANDMARK_RADIUS_M = 400; // "the area around X" — a few blocks (300–500 m)
|
||||
@@ -1281,7 +1281,7 @@ const ENCLOSING_RADIUS_M = 600; // sweep this far for an enclosing named non-bui
|
||||
* leisure=park). OSM has no deterministic "parent polygon" call and the canonical
|
||||
* compound name is rarely what the user utters ("grounds", not "Capitol Square"),
|
||||
* so SELECTION is by containment → smallest area, with a name-match only as a
|
||||
* tiebreak BONUS (never a filter).
|
||||
* tiebreak BONUS (never a filter). See docs/compound-containment-research.md §1.4–1.5.
|
||||
*
|
||||
* Modeled on fetchLocalMonument: a 12 s fail-fast (an enrichment, not worth blocking
|
||||
* narration — and narration no longer waits on it since outlines went progressive, so
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Footprint-selection contract tests — pure fixtures, no network, no browser.
|
||||
//
|
||||
// Locks the monument-resolution regression:
|
||||
// Locks the field-test-7 monument fix (docs/field-test-rootcause-2026-06-30.md §1):
|
||||
// a POINT-LIKE target ("Tejano Monument, Austin") must never adopt a nearby
|
||||
// polygon that merely shares locality/context words ("Austin", "History").
|
||||
// The fixtures replicate the REAL Overpass candidates captured over the Texas
|
||||
|
||||
@@ -359,7 +359,7 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
// ── Partial-add rollback (second review) ─────────────────────────────────────
|
||||
// ── Partial-add rollback (review round 2) ─────────────────────────────────────
|
||||
//
|
||||
// The hybrid builds a mark across TWO sub-renderers. It used to record the
|
||||
// route only after both had run, so a throw in the second one left the first
|
||||
|
||||
@@ -6,9 +6,9 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
* for manual/dev use via `window.__gevAnnotations`.
|
||||
*
|
||||
* This module is the single swap point between annotation rendering strategies.
|
||||
* The HYBRID renderer uses world-space draping for
|
||||
* This branch (Direction C) uses the HYBRID renderer: world-space draping for
|
||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||
* and voice tool wiring are shared across rendering strategies.
|
||||
* and voice tool wiring are identical to the other two branches.
|
||||
*/
|
||||
export function initAnnotations({ viewer, tileset = null }) {
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
|
||||
@@ -259,7 +259,7 @@ test('annotation fade consumes the actual tracked host paint rectangle after lay
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
// ── Partial-add unwind (second review) ───────────────────────────────────────
|
||||
// ── Partial-add unwind (review round 2) ───────────────────────────────────────
|
||||
//
|
||||
// add() inserts the group and records it, then does more live-document work
|
||||
// (draw-on wiring, the first projection pass). A throw in that tail used to
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Camera verbs — the "spy satellite simulator" feel
|
||||
* documented in `docs/CURRENT-STATE.md`.
|
||||
* (docs/superpowers/specs/2026-07-23-camera-verbs-fly-route-spec.md).
|
||||
*
|
||||
* One motion at a time, driven per clock tick. `once` = bounded eased nudge;
|
||||
* `continuous` runs until move_camera{stop}, ANY manual camera input on the
|
||||
@@ -31,7 +31,7 @@ const PITCH_MIN = Cesium.Math.toRadians(-89);
|
||||
const PITCH_MAX = Cesium.Math.toRadians(-5);
|
||||
|
||||
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
|
||||
* Every knob an maintainer may want to retune lives in this block. The shaping is
|
||||
* Every knob an owner may want to retune lives in this block. The shaping is
|
||||
* built from four independent layers, each of which flattens to nothing on its
|
||||
* own: a trapezoid speed profile, a banked-turn roll, altitude breathing, and
|
||||
* a gaze that leads the path. Under prefers-reduced-motion the last three are
|
||||
|
||||
@@ -11,13 +11,13 @@ export const GLOBE_EXIT_CLEARANCE_PX = 12;
|
||||
export const CELESTIAL_PLANE_EPSILON = 0.045;
|
||||
/** Responsive radial fade band used by every keyhole-aligned text overlay —
|
||||
* this is the Detection FADE (label/card fading), NOT the scope-mask feather
|
||||
* in scopeMask.js. 0.07 since the 2026-08-24 final value (was 0.16). */
|
||||
* in scopeMask.js. 0.07 since the 2026-08-24 owner final lock (was 0.16). */
|
||||
export const KEYHOLE_LABEL_FEATHER_RATIO = 0.07;
|
||||
export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
|
||||
/**
|
||||
* First-run OUTSIDE opacity for keyhole-aligned world overlays.
|
||||
*
|
||||
* 0.01 since 2026-08-24 (final value; 0.03 on 08-23, 0.05 before). Keep in lockstep with
|
||||
* 0.01 since 2026-08-24 (owner final lock; 0.03 on 08-23, 0.05 before). Keep in lockstep with
|
||||
* `#detection-opacity-slider`'s markup value AND readout in index.html,
|
||||
* `_detectionOutsideOpacityPct` in sharelink.js,
|
||||
* `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in ui.js, and
|
||||
@@ -374,7 +374,7 @@ export class CelestialRing {
|
||||
// Always mark dirty so a long-hidden interval can't serve stale
|
||||
// sun/moon vectors on return — but only request the repaint frame
|
||||
// while visible; the visibility-restore request (main.js) picks the
|
||||
// dirty flag up immediately. (review review finding)
|
||||
// dirty flag up immediately. (review finding)
|
||||
this._ephemerisDirty = true;
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
governorRequestRender('celestial-ephemeris');
|
||||
@@ -427,7 +427,7 @@ export class CelestialRing {
|
||||
// rendered frames — under the idle render governor an enable (or the
|
||||
// clearing disable) must request its frame or the ring never draws at
|
||||
// all. Camera motion covers every later repaint; the 60 s ephemeris
|
||||
// timer requests its own. (perf wave 2 fix — field test finding)
|
||||
// timer requests its own. (perf wave 2 fix — owner playtest finding)
|
||||
if (this.enabled !== wasEnabled) governorRequestRender('celestial-ring');
|
||||
this._root.classList.toggle('disabled', !this.enabled);
|
||||
if (!this.enabled) {
|
||||
|
||||
@@ -44,7 +44,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/body\.cockpit-mode #view-switcher \{[\s\S]*?bottom: max\(clamp\(128px, 15vh, 150px\), env\(safe-area-inset-bottom\)\);[\s\S]*?margin-bottom: -95px;/,
|
||||
'Cockpit exit must retain the accepted bottom-center position',
|
||||
'Cockpit exit must retain the owner-approved bottom-center position',
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
|
||||
@@ -253,7 +253,7 @@ test('Contact panel hides only when there is no snapshot at all', () => {
|
||||
|
||||
test('Contact panel survives NEXT onto a vessel or installation subject', () => {
|
||||
// The panel owns the NEXT button. Hiding it because the subject is not the
|
||||
// tracked aircraft strands the operator with no way back (this was observed on
|
||||
// tracked aircraft strands the operator with no way back (owner hit this on
|
||||
// camera: "click next... whole left panel disappears").
|
||||
for (const subject of [
|
||||
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/contactsDetectionPolicy.js — Contacts-scoped detection policy.
|
||||
//
|
||||
// Field test 2026-08-18: "when you click on Contacts, detections should just
|
||||
// Owner playtest 2026-08-18: "when you click on Contacts, detections should just
|
||||
// turn on, and they should stay on in Cockpit or in third-person tracking inside
|
||||
// Contacts or inside Cockpit, both… when I leave the Cockpit, detections go off"
|
||||
// — that last part being the bug.
|
||||
@@ -88,7 +88,7 @@ export function contactsDetectionExitPlan(restore, current, styleOwnsDetection =
|
||||
*
|
||||
* The engine is injected so this is the SAME code the UI runs and the tests
|
||||
* exercise, driven against the real `src/data/detection.js` in both. Turning on
|
||||
* applies the TACTICAL PRESET the military styles use — field test: "I want
|
||||
* applies the TACTICAL PRESET the military styles use — owner playtest: "I want
|
||||
* that as the default. It should just happen" — rather than restoring whatever
|
||||
* profile the operator last left detection at.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Contacts-scoped detection (field test 2026-08-18: "when you click on
|
||||
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
|
||||
// Contacts, detections should just turn on, and they should stay on in Cockpit
|
||||
// or in third-person tracking inside Contacts").
|
||||
//
|
||||
@@ -87,7 +87,7 @@ test('the snapshot carries every field activation mutates, not just the mode', (
|
||||
assert.equal(getDetectionMode(), 'OFF');
|
||||
assert.equal(getDetectionTuning().densityPct, 25, 'deactivation puts the density back');
|
||||
|
||||
// The pin the user would notice: the next manual enable returns their profile.
|
||||
// The pin the owner would feel: the next manual enable returns their profile.
|
||||
cycleDetectionMode();
|
||||
assert.equal(getDetectionMode(), 'SPARSE', 'manual enable returns 25%, not the tactical 75%');
|
||||
assert.equal(getDetectionTuning().densityPct, 25);
|
||||
@@ -107,7 +107,7 @@ test('a density-only difference is still a restore worth making', () => {
|
||||
});
|
||||
|
||||
test('activating Contacts lands on the tactical preset, not the last profile used', () => {
|
||||
// Field test: the Contacts default is the military look, and it "should
|
||||
// Owner playtest: the Contacts default is the military look, and it "should
|
||||
// just happen" — so a SPARSE session does NOT drag SPARSE into Contacts.
|
||||
setDetectionTuning({ densityPct: 25 });
|
||||
startAt('SPARSE');
|
||||
@@ -159,7 +159,7 @@ test('activating Contacts leaves an already-on profile untouched', () => {
|
||||
});
|
||||
|
||||
test('detection survives cockpit enter and exit inside a Contacts session', () => {
|
||||
// The observed bug: "when I leave the Cockpit, detections go off".
|
||||
// The owner's actual bug: "when I leave the Cockpit, detections go off".
|
||||
// Cockpit is a move WITHIN Contacts and must not touch detection at all, so
|
||||
// the only transitions here are the Contacts ones — repeated syncs while the
|
||||
// session stays active.
|
||||
|
||||
@@ -271,7 +271,7 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
|
||||
// The defect this replaced was the LIE, not the OFF: the transition reported
|
||||
// a bare "did not complete" while the operator's Context was silently gone.
|
||||
// Text and state are derived from the same verdict so they cannot disagree,
|
||||
// and the failed layer ids survive (the honesty requirement).
|
||||
// and the failed layer ids survive (Manjunath's honesty requirement).
|
||||
const setter = src.slice(
|
||||
src.indexOf(' async setContextMode(mode, {'),
|
||||
src.indexOf(' getCockpitState() {'),
|
||||
|
||||
@@ -137,7 +137,7 @@ export const CLASS_MODEL_URL = {
|
||||
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
|
||||
};
|
||||
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, selected assets; CC-BY 4.0 —
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
|
||||
* provenance in public/models/README.md). Every asset is vertex-baked to the
|
||||
* airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers'
|
||||
* MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* white / cyan-tracked / amber-military, plus .withAlpha fades) keeps working —
|
||||
* no per-glyph hardcoded colors that would fight the tint.
|
||||
*
|
||||
* MIXED-SET UPDATE (2026-08-15, selected asset set): airliner/widebody/
|
||||
* MIXED-SET UPDATE (2026-08-15, owner Hangar picks): airliner/widebody/
|
||||
* turboprop/helicopter use the "refined" recognition-chart redraw;
|
||||
* quadjet/glider use the "bold" chart-symbol redraw; light/fastjet keep the
|
||||
* original drawings. Raster fidelity doubled (192px source, same 96 coords).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/data/aisLiveVessels.test.mjs
|
||||
// Focused tests for the AIS feed-status derivation helper (Batch 10, finding H3/AIS)
|
||||
// and the vessel vertical-datum seam (2026-07-27 datum pass — see
|
||||
// the vessel datum contract in docs/CURRENT-STATE.md).
|
||||
// docs/superpowers/specs/2026-07-27-vessel-datum-design.md).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
@@ -167,7 +167,7 @@ test('P0: a pre-disposal close event cannot erase the post-disposal socket', asy
|
||||
assert.deepEqual(context.adapter.debug().generations, [2],
|
||||
'the replacement must NOT reuse generation 1');
|
||||
|
||||
// The pre-disposal socket's close finally lands — review's exact interleaving.
|
||||
// The pre-disposal socket's close finally lands — the exact adversarial interleaving.
|
||||
first.flushClose();
|
||||
|
||||
const debug = context.adapter.debug();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* client-side in the layers ("how many flights over Texas?", "biggest fire
|
||||
* near LA?", "which ships are headed to Oakland?").
|
||||
*
|
||||
* Analyst-query behavior is documented in docs/CURRENT-STATE.md:
|
||||
* Design (owner-ratified, docs/voice-engine-evaluation-2026-07-23.md §5.3):
|
||||
* - ENGINE (this module) is pure query logic over plain record arrays; it
|
||||
* renders nothing. SURFACES (voice narration, panels, detection brackets)
|
||||
* consume the returned result set — the engine/surface seam is the
|
||||
|
||||
+15
-15
@@ -140,7 +140,7 @@ const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
|
||||
// Throttle for placeholder repaints — the projection RAF loop must not
|
||||
// re-fill a 1080p canvas on every frame while a feed image is still loading.
|
||||
const PLACEHOLDER_REPAINT_MS = 750;
|
||||
// v1 key is retired dead data (product rule #3, §9.3 — WIPE CLEAN, no
|
||||
// v1 key is retired dead data (owner decision #3, §9.3 — WIPE CLEAN, no
|
||||
// legacy import): kept here only as a documented constant so nothing ever
|
||||
// re-reads it by accident. Exported for the unit suite's "v1 is ignored"
|
||||
// assertion; there is NO read path for this key anywhere in the module.
|
||||
@@ -366,7 +366,7 @@ let _cardFetchMode = 'steady';
|
||||
* 20/28/40 tiers resume when loading completes (see refreshAmbientCards).
|
||||
*/
|
||||
const CCTV_AMBIENT_CARD_DRAIN_CAP = 16;
|
||||
// Global static-frame pacing (field finding 3): the pacer ticks at the burst
|
||||
// Global static-frame pacing (owner finding 3): the pacer ticks at the burst
|
||||
// spacing (250 ms) but cardFetchPolicy gates launches — cold fill (selected
|
||||
// cards still missing their FIRST frame) allows up to 4 in-flight fetches at
|
||||
// 250 ms spacing; steady state keeps the salvaged Part C gate of at most one
|
||||
@@ -403,7 +403,7 @@ let _projectionOverlayOwnerId = null;
|
||||
* thumbnail absent because its monitor plane is the active representation.
|
||||
*/
|
||||
let _activeCameraCardEnabled = false;
|
||||
// Hover-summoned card (follow-up round 2, item B): pointing at a cardless camera
|
||||
// Hover-summoned card (owner round 2, item B): pointing at a cardless camera
|
||||
// icon shows its card immediately as a PINNED entry (budget-exempt, top
|
||||
// draw-pass declutter priority).
|
||||
/** Min spacing between hover scene.pick calls (event-driven, user gesture). */
|
||||
@@ -693,7 +693,7 @@ function safeWindowLocalStorage() {
|
||||
*
|
||||
* v2 entries carry provenance: `{ values: <7-field offsets>, source: 'manual',
|
||||
* savedAt: <epoch ms> }`. The v1 key (`CCTV_CALIBRATION_STORAGE_KEY_V1`) is
|
||||
* NEVER read here — product rule #3 (§9.3): wipe clean, no legacy import.
|
||||
* NEVER read here — owner decision #3 (§9.3): wipe clean, no legacy import.
|
||||
*
|
||||
* @param {{getItem:function}|null} [storage] - Injectable storage (defaults
|
||||
* to `window.localStorage`); lets the unit suite test this pure of a DOM.
|
||||
@@ -1186,7 +1186,7 @@ function buildCatalogFromSources(rawSources) {
|
||||
* tileset is present (OSM fallback) this returns true so ground sampling is
|
||||
* not permanently blocked.
|
||||
*
|
||||
* Task 5 (spec correction, spec §2): a HIDDEN tileset (`show === false`,
|
||||
* Task 5 (review correction, spec §2): a HIDDEN tileset (`show === false`,
|
||||
* i.e. a globe stack is active) must NOT report ready — Cesium 1.138's
|
||||
* the shared sampler can only inspect *visible* 3D tilesets, so a sample taken
|
||||
* against the hidden Google tileset would silently miss.
|
||||
@@ -1456,7 +1456,7 @@ function refreshProjectionTextures(record) {
|
||||
// Only swap when the canvas content actually changed since the last swap.
|
||||
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
|
||||
// canvas re-uploads the texture for nothing, and each material image
|
||||
// reassignment is a flash opportunity on the live plane (field test
|
||||
// reassignment is a flash opportunity on the live plane (owner field test
|
||||
// 2026-07-04: intermittent white flashes on the monitor plane).
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
|
||||
|
||||
@@ -1884,7 +1884,7 @@ function drawProjectionFrame(record) {
|
||||
// and a fresh 1920x1080 texture upload; the plane renders its white
|
||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||
// field tests (2026-07-04 and 2026-07-30).
|
||||
// owner field tests (2026-07-04 and 2026-07-30).
|
||||
const signature = projectionFrameSignature(runtime);
|
||||
runtime.drawnImageStamp = runtime.imageStamp;
|
||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||
@@ -2732,7 +2732,7 @@ function refreshHorizonCulling() {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ambient card tier (2026-07-29 design — spec:
|
||||
// docs/CURRENT-STATE.md)
|
||||
// docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns (creating on demand) the stable frame slot for a camera id. */
|
||||
@@ -2805,7 +2805,7 @@ function refreshAmbientCards() {
|
||||
});
|
||||
}
|
||||
|
||||
// Field finding 4: current card holders rank with the 20% incumbency
|
||||
// Owner finding 4: current card holders rank with the 20% incumbency
|
||||
// distance discount, so a small camera move never batch-swaps the ring.
|
||||
// Item C: passing the viewport dims + per-candidate screen anchors routes
|
||||
// the budget fill through the screen-distribution grid, so periphery
|
||||
@@ -2840,7 +2840,7 @@ function refreshAmbientCards() {
|
||||
})),
|
||||
{ limit: cardLimit }
|
||||
);
|
||||
// Field finding 2: grace must never apply to the active camera — drop any
|
||||
// Owner finding 2: grace must never apply to the active camera — drop any
|
||||
// lingering grace entry and keep it out of the retained-card baseline.
|
||||
if (activeId) {
|
||||
_cardIds.delete(activeId);
|
||||
@@ -2911,7 +2911,7 @@ function pushAmbientCardEntries() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled MOUSE_MOVE hover pass (follow-up round 2, item B): pointing at a
|
||||
* Throttled MOUSE_MOVE hover pass (owner round 2, item B): pointing at a
|
||||
* camera icon that has no card summons its card immediately. This is
|
||||
* EVENT-DRIVEN picking on a user gesture, not steady-state work — the
|
||||
* ≥120 ms throttle caps it at ~8 scene.pick calls/s while the pointer is
|
||||
@@ -3008,7 +3008,7 @@ function hoverFetchCardFrame(record) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-frame pacer tick (field finding 3): launches AT MOST one fetch per
|
||||
* Card-frame pacer tick (owner finding 3): launches AT MOST one fetch per
|
||||
* tick, with cardFetchPolicy deciding whether a launch is allowed. Cold fill
|
||||
* — any selected card still missing its FIRST frame — bursts up to 4
|
||||
* in-flight fetches at 250 ms spacing so arriving in a new area populates
|
||||
@@ -3592,7 +3592,7 @@ function ensureGizmo() {
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.1 activation obstruction probe (LOCKED product rule): on camera
|
||||
* §9.1 activation obstruction probe (LOCKED owner decision): on camera
|
||||
* ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis
|
||||
* (mount → cap-center direction). If it hits the tiles closer than the pose
|
||||
* range, clamp the plane's effective range just short of the first hit so the
|
||||
@@ -4362,7 +4362,7 @@ const cctvLayer = {
|
||||
// remain eligible for true empty-space deselection.
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId !== null) return;
|
||||
// Item A (follow-up round 2): the scene pick found no camera — try the
|
||||
// Item A (owner round 2): the scene pick found no camera — try the
|
||||
// painted ambient cards. The cards canvas is pointer-events:none (this
|
||||
// handler owns the events), so a click landing on a card's rect selects
|
||||
// its camera exactly like a click on the icon. Cesium click positions
|
||||
@@ -4451,7 +4451,7 @@ const cctvLayer = {
|
||||
_removeFocusAppearListener = null;
|
||||
stopProjectionLoop();
|
||||
stopGeometryLoadQueue();
|
||||
// Ambient cards tear down COMPLETELY on disable (product design point 6):
|
||||
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
|
||||
// source entries, pacer timer, in-flight handlers, and caches.
|
||||
teardownAmbientCards();
|
||||
hideCctvVisuals();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/data/cctv.test.mjs — CCTV v2 pure frustum geometry (computeFrustumGeometry).
|
||||
//
|
||||
// Locks the CCTV frustum geometry described in docs/CURRENT-STATE.md:
|
||||
// Locks the §2a math of docs/plans/2026-07-03-cctv-v2-design.md:
|
||||
// - the far-cap (monitor plane) corners lie ON the plane through capCenter
|
||||
// perpendicular to the frustum view axis (ε < 0.5 m) — this is the geometric
|
||||
// invariant that welds the wireframe corner rays to the plane entity;
|
||||
@@ -402,7 +402,7 @@ test('ground clamp lifts the CAP CENTER only — the rectangle stays rigid (true
|
||||
assert.equal(g.capCenter.alt, floor, 'cap center clamps exactly to the floor');
|
||||
// Corners derive rigidly from the lifted center: alt = floor ± cos(pitch)·halfH.
|
||||
// The bottom pair sits BELOW the floor (tiles occlude it) — per-corner clamping
|
||||
// is what flattened the wireframe into a fan (field test 2026-07-04).
|
||||
// is what flattened the wireframe into a fan (owner field test 2026-07-04).
|
||||
const upVert = Math.cos(toRad(-24)) * g.halfH;
|
||||
for (const key of ['tl', 'tr']) {
|
||||
assert.ok(Math.abs(g.corners[key].alt - (floor + upVert)) < 1e-6, `${key} alt ${g.corners[key].alt}`);
|
||||
@@ -415,7 +415,7 @@ test('ground clamp lifts the CAP CENTER only — the rectangle stays rigid (true
|
||||
test('clamped pose keeps corner/plane coincidence and the rigid 2·halfW × 2·halfH span', () => {
|
||||
// The wireframe corner rays must terminate exactly on the monitor plane's
|
||||
// corners AT THE DEFAULT AUSTIN POSE — this is the case that diverged by
|
||||
// ~47.5 m under per-corner clamping (field test 2026-07-04).
|
||||
// ~47.5 m under per-corner clamping (owner field test 2026-07-04).
|
||||
const g = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||||
const d = viewDir(41, -24);
|
||||
const capEnu = enu(g.mount, g.capCenter);
|
||||
@@ -1090,7 +1090,7 @@ test('heading wrap: heading 350° produces a symmetric cap (left/right corners e
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 5 — calibration v2 store + CAL badge (design §3b/§3c as amended by the
|
||||
// LOCKED product rules §9.2/§9.3: wipe-clean v2 store, panel-only badge).
|
||||
// LOCKED owner decisions §9.2/§9.3: wipe-clean v2 store, panel-only badge).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal in-memory localStorage stand-in for pure store-IO unit tests. */
|
||||
@@ -1167,7 +1167,7 @@ test('calibration v2: a corrupt v1 key never leaks into the v2 store (v1 is dead
|
||||
});
|
||||
// v2 key is untouched/empty — v1's presence must have zero effect.
|
||||
const restored = readCalibrationStoreV2(storage);
|
||||
assert.equal(restored.size, 0, 'v2 store must start empty — no legacy import (product rule #3, §9.3)');
|
||||
assert.equal(restored.size, 0, 'v2 store must start empty — no legacy import (owner decision #3, §9.3)');
|
||||
assert.ok(!restored.has('austin-42'));
|
||||
});
|
||||
|
||||
@@ -1190,7 +1190,7 @@ test('deriveCalBadge: RAW PRIOR for everything else (all Austin Open Data today)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 5 (height-datum fix): regime-aware ground resolution pure helpers.
|
||||
// the height-datum contract in docs/CURRENT-STATE.md.
|
||||
// docs/superpowers/specs/2026-07-05-entity-height-datum-design.md §2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('surfaceRegimeKey: globe hidden (photoreal) → google-3d; globe visible → terrain-globe', () => {
|
||||
@@ -1236,7 +1236,7 @@ test('normalizeCoverageMode: garbage keeps the current mode', () => {
|
||||
assert.equal(normalizeCoverageMode(3, 'on'), 'on');
|
||||
});
|
||||
|
||||
// Unchanged-frame signature (white-flash fix, field test 2026-07-30)
|
||||
// Unchanged-frame signature (white-flash fix, owner field test 2026-07-30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Builds an RGBA buffer from [r,g,b] triples. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @module cctvCards
|
||||
* @description Screen-space thumbnail cards for the citywide ambient CCTV
|
||||
* tier described in `docs/CURRENT-STATE.md`.
|
||||
* tier (design: docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md).
|
||||
* Replaces the rejected world-space static-plane ring with small canvas cards
|
||||
* anchored to each LOD-selected camera's screen position, showing its latest
|
||||
* paced static frame.
|
||||
@@ -18,10 +18,10 @@
|
||||
* persistence, retry pacing, cache pruning) and supplies ready-to-draw stable
|
||||
* frame-slot references to the host.
|
||||
*
|
||||
* Zero-flicker contract (product requirement):
|
||||
* Zero-flicker contract (owner requirement):
|
||||
* - An AMBIENT entry whose frame slot has no drawn frame yet (`stamp === 0`)
|
||||
* renders NOTHING — no placeholder, no chip. The camera icon alone carries
|
||||
* it. Sole documented exception (follow-up round 2, item B): a PINNED entry
|
||||
* it. Sole documented exception (owner round 2, item B): a PINNED entry
|
||||
* (hover-summoned, `entry.pinned === true`) paints its chrome immediately —
|
||||
* explicit user gesture wants instant feedback — with an empty thumb area
|
||||
* until its fast-tracked frame lands.
|
||||
@@ -48,7 +48,7 @@ export const CCTV_FRAME_CANVAS_W = 192;
|
||||
export const CCTV_FRAME_CANVAS_H = 108;
|
||||
/**
|
||||
* Min screen separation between accepted card anchors (greedy declutter).
|
||||
* Field test 2026-07-30: 130 read too sparse once the HUD safe-zone
|
||||
* Owner field test 2026-07-30: 130 read too sparse once the HUD safe-zone
|
||||
* filter started dropping cards as well. 112 still exceeds the card box width
|
||||
* (104 px) so accepted boxes cannot overlap. This is THE density knob.
|
||||
*/
|
||||
@@ -59,8 +59,8 @@ export const CCTV_CARD_SAFE_TOP_MAX_PX = 150;
|
||||
/** Bounded thumbnail cache (frame slots kept beyond the live card set). */
|
||||
export const CCTV_FRAME_CACHE_MAX = 96;
|
||||
|
||||
// ─── Altitude scaling (field test finding 5, 2026-07-29) ──────────────
|
||||
// Validated curve: cards are full size at street level, "start to get
|
||||
// ─── Altitude scaling (owner field-test finding 5, 2026-07-29) ──────────────
|
||||
// Owner-decided curve: cards are full size at street level, "start to get
|
||||
// smaller" from ~1,800 m, "scale down progressively" to ~0.45 by 6,000 m,
|
||||
// keep shrinking slightly and alpha-fade out across 7,500→9,500 m, and are
|
||||
// fully hidden above that ("just the icons" at the highest zooms).
|
||||
@@ -71,7 +71,7 @@ export const CCTV_CARD_FADE_END_M = 9_500;
|
||||
export const CCTV_CARD_SCALE_AT_MID = 0.45;
|
||||
export const CCTV_CARD_SCALE_MIN = 0.35;
|
||||
|
||||
// ─── Frame-fetch pacing (field test finding 3, 2026-07-29) ────────────
|
||||
// ─── Frame-fetch pacing (owner field-test finding 3, 2026-07-29) ────────────
|
||||
/** Steady-state global gate: one card-frame fetch per second. */
|
||||
export const CCTV_CARD_FETCH_STEADY_SPACING_MS = 1_000;
|
||||
/** Cold-fill burst spacing between fetch launches. */
|
||||
@@ -150,7 +150,7 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude-driven card scale + opacity (field test finding 5,
|
||||
* Altitude-driven card scale + opacity (owner field-test finding 5,
|
||||
* 2026-07-29 — curve constants above). Piecewise, monotonic non-increasing
|
||||
* in both channels:
|
||||
* - ≤1,800 m: full size, fully opaque.
|
||||
@@ -178,7 +178,7 @@ export function cardScaleForAltitude(cameraHeightM) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-fill burst pacing policy (field test finding 3, 2026-07-29), as
|
||||
* Cold-fill burst pacing policy (owner field-test finding 3, 2026-07-29), as
|
||||
* a pure decision so the pacer tick stays trivially testable. While any
|
||||
* selected card still lacks its FIRST frame (`coldFill`), up to
|
||||
* `CCTV_CARD_FETCH_BURST_LIMIT` fetches may be in flight with
|
||||
|
||||
@@ -129,7 +129,7 @@ test('CCTV card module cannot resurrect a canvas, projection, listener, or priva
|
||||
for (const pattern of forbidden) assert.doesNotMatch(source, pattern);
|
||||
});
|
||||
|
||||
// ─── cardScaleForAltitude — the validated altitude curve (finding 5) ────
|
||||
// ─── cardScaleForAltitude — the owner-decided altitude curve (finding 5) ────
|
||||
|
||||
test('cardScaleForAltitude: full size and opacity at or below 1,800 m', () => {
|
||||
assert.deepEqual(cardScaleForAltitude(0), { scale: 1, alpha: 1 });
|
||||
@@ -137,7 +137,7 @@ test('cardScaleForAltitude: full size and opacity at or below 1,800 m', () => {
|
||||
assert.deepEqual(cardScaleForAltitude(NaN), { scale: 1, alpha: 1 });
|
||||
});
|
||||
|
||||
test('cardScaleForAltitude: hits the validated waypoints', () => {
|
||||
test('cardScaleForAltitude: hits the owner-decided waypoints', () => {
|
||||
const mid = cardScaleForAltitude(CCTV_CARD_SCALE_MID_M);
|
||||
assert.ok(Math.abs(mid.scale - CCTV_CARD_SCALE_AT_MID) < 1e-9, 'scale ~0.45 at 6,000 m');
|
||||
assert.equal(mid.alpha, 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @module cctvGizmo
|
||||
*
|
||||
* Direct-manipulation calibration gizmo for the CCTV layer (design:
|
||||
* the CCTV calibration contract in `docs/CURRENT-STATE.md`).
|
||||
* docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3c).
|
||||
*
|
||||
* Two layers:
|
||||
* - Pure drag math (this top section): ray↔axis closest-point, ray↔plane
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/cctvGizmo.test.mjs — pure drag math for the CCTV calibration gizmo
|
||||
// documented in docs/CURRENT-STATE.md.
|
||||
// (docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3c).
|
||||
//
|
||||
// Locks:
|
||||
// - closestParamOnAxis returns the metre-parameter along the AXIS of the
|
||||
|
||||
+12
-11
@@ -7,15 +7,16 @@
|
||||
* card set, keeps that set stable across small camera moves (eviction
|
||||
* grace), and paces static-frame refreshes per source.
|
||||
*
|
||||
* The engine retains zoom-scaled budgets, distance-ranked in-view selection,
|
||||
* an eviction-grace planner, and source-aware refresh cadences, retargeted
|
||||
* from the rejected world-space static-plane ring to the
|
||||
* Engine adapted from Manjunath's Part C work (`fix/cctv-part-c-review`):
|
||||
* the zoom-scaled budgets, distance-ranked in-view selection, the
|
||||
* eviction-grace planner, and the source-aware refresh cadences are his,
|
||||
* retargeted from the rejected world-space static-plane ring to the
|
||||
* screen-space thumbnail cards (see
|
||||
* the ambient-card behavior documented in `docs/CURRENT-STATE.md`).
|
||||
* docs/superpowers/specs/2026-07-29-cctv-ambient-cards-design.md).
|
||||
*/
|
||||
|
||||
// Ambient-card budgets (follow-up round 2, item C: raised 16/24/32 → 20/28/40 —
|
||||
// "a lot of empty space"). Tunable as a set, together with the card
|
||||
// Ambient-card budgets (owner round 2, item C: raised 16/24/32 → 20/28/40 —
|
||||
// "a lot of empty space"). Owner-tunable as a set, together with the card
|
||||
// scale waypoints (1,800/6,000/9,500 m, cctvCards.js) and
|
||||
// CCTV_CARD_MIN_SEP_PX: budgets say how many cameras HOLD cards, the
|
||||
// waypoints and separation say how many fit on screen.
|
||||
@@ -34,7 +35,7 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
|
||||
/**
|
||||
* Returns the bounded ambient-card budget for the current viewer height.
|
||||
* Card counts stay inside the 20..40 range (follow-up round 2): street level
|
||||
* Card counts stay inside the 20..40 range (owner round 2): street level
|
||||
* keeps the overlay sparse, metro scale earns the full ring.
|
||||
*
|
||||
* @param {number} cameraHeightM
|
||||
@@ -52,7 +53,7 @@ export function cctvLodBudgets(cameraHeightM) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-level incumbency (field test finding 4, 2026-07-29): a
|
||||
* Selection-level incumbency (owner field-test finding 4, 2026-07-29): a
|
||||
* camera currently holding a card ranks with its distance discounted by this
|
||||
* factor, so a small camera move never batch-swaps the ring — a non-carded
|
||||
* camera displaces a carded one only when it is meaningfully (>20%) closer.
|
||||
@@ -144,17 +145,17 @@ export function blendCenterRankKm(
|
||||
return (1 - w) * km + w * spread * fraction;
|
||||
}
|
||||
|
||||
// Screen-distribution grid (follow-up round 2, item C — "a lot of empty space"):
|
||||
// Screen-distribution grid (owner round 2, item C — "a lot of empty space"):
|
||||
// pure nearest-first selection clusters winners at screen center (nearest ==
|
||||
// most central at typical view pitch) and leaves the periphery bare. The
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Tunable together with
|
||||
// candidate before global rank fills the rest. Owner-tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (follow-up round 2, item C). Buckets the
|
||||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||||
* viewport into a CCTV_CARD_GRID_COLS × CCTV_CARD_GRID_ROWS grid, assigns
|
||||
* each candidate to its cell (anchors just outside the viewport clamp to
|
||||
* the edge cells), ranks within cells by effective distance (`rankKm` —
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/data/cctvLod.test.mjs
|
||||
// Pure LOD-engine tests, adapted from earlier Part C suite
|
||||
// zoom-scaled card budgets, nearest-first in-view
|
||||
// Pure LOD-engine tests, adapted from Manjunath's Part C suite
|
||||
// (fix/cctv-part-c-review): zoom-scaled card budgets, nearest-first in-view
|
||||
// selection, video exclusion, the eviction-grace planner, and source-aware
|
||||
// static-frame pacing.
|
||||
import { test } from 'node:test';
|
||||
@@ -35,7 +35,7 @@ function candidates(count, options = {}) {
|
||||
}
|
||||
|
||||
test('cctvLodBudgets scales the card budget from 20 to 40 with view height', () => {
|
||||
// Follow-up round 2 (item C): budgets raised 16/24/32 -> 20/28/40.
|
||||
// Owner round 2 (item C): budgets raised 16/24/32 -> 20/28/40.
|
||||
assert.equal(CCTV_AMBIENT_CARD_MIN, 20);
|
||||
assert.equal(CCTV_AMBIENT_CARD_MID, 28);
|
||||
assert.equal(CCTV_AMBIENT_CARD_MAX, 40);
|
||||
@@ -71,7 +71,7 @@ test('selectCctvLod tolerates malformed candidate rows', () => {
|
||||
assert.deepEqual(selected.cardIds, ['cam-ok']);
|
||||
});
|
||||
|
||||
// ─── Selection-level incumbency (field test finding 4) ────────────────
|
||||
// ─── Selection-level incumbency (owner field-test finding 4) ────────────────
|
||||
|
||||
test('incumbentRankKm discounts incumbents by the 20% factor', () => {
|
||||
assert.equal(incumbentRankKm(10, false), 10);
|
||||
@@ -121,7 +121,7 @@ test('selectCctvLod: no incumbents means plain nearest-first (unchanged behavior
|
||||
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
|
||||
});
|
||||
|
||||
// ─── distributeCctvCards — screen distribution (follow-up round 2, item C) ──────
|
||||
// ─── distributeCctvCards — screen distribution (owner round 2, item C) ──────
|
||||
// Grid: 5 cols x 4 rows. At viewW 1000 / viewH 800 each cell is 200x200 px.
|
||||
|
||||
test('distributeCctvCards spreads clustered candidates across cells', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @module cctvViewshed
|
||||
*
|
||||
* Viewshed presentation for the CCTV layer (design:
|
||||
* the CCTV viewshed geometry contract).
|
||||
* docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3a/§3b).
|
||||
*
|
||||
* Two responsibilities, both pure of layer state:
|
||||
* - Color identity: a stable per-camera hue (golden-angle spaced over the
|
||||
@@ -30,7 +30,7 @@ const LINE_ALPHA_ACTIVE = 1.0;
|
||||
* Stable hue (degrees, [0, 360)) for a camera's position in the id-sorted
|
||||
* catalog. Golden-angle spacing keeps any local cluster of neighbor cameras
|
||||
* visually separated; id-sorting makes the assignment deterministic across
|
||||
* sessions for a stable catalog (design §3a, open question Q4).
|
||||
* sessions for a stable catalog (design §3a, owner question Q4).
|
||||
* @param {number} index - Camera index in the id-sorted catalog.
|
||||
* @returns {number} Hue in degrees.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/cctvViewshed.test.mjs — viewshed hue assignment + frustum volume
|
||||
// geometry documented in docs/CURRENT-STATE.md.
|
||||
// geometry (docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3a/§3b).
|
||||
//
|
||||
// Locks:
|
||||
// - cameraHue is golden-angle spaced and deterministic (color identity is
|
||||
|
||||
@@ -42,7 +42,7 @@ for (const layer of LAYERS) {
|
||||
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
|
||||
'Cockpit must not bypass the user-visible Display toggle');
|
||||
'Cockpit must not bypass the owner-visible Display toggle');
|
||||
});
|
||||
|
||||
test(`${layer.name}: the pilot's own airframe stays hidden in cockpit`, () => {
|
||||
|
||||
@@ -92,7 +92,7 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
|
||||
* (`track_entity`) is a MUTATION fulfilling "follow that one" — returning an
|
||||
* ambiguity for the caller to resolve would cost a round-trip mid-demo, and
|
||||
* the model's observed response to a non-ok track result is to retry with
|
||||
* different guesses rather than to ask (field session 2026-08-21,
|
||||
* different guesses rather than to ask (owner field session 2026-08-21,
|
||||
* 23:48). So the lookup always commits. What it owes the caller is STABILITY:
|
||||
* hex is unique and always present, so the same query resolves to the same
|
||||
* contact for as long as both are loaded, instead of flipping between polls
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as Cesium from 'cesium';
|
||||
* Per-layer data attribution registered into Cesium's credit display.
|
||||
*
|
||||
* Legal requirement (see DATA_SOURCES.md, findings H10/H11 in
|
||||
* every third-party data layer this app can
|
||||
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
|
||||
* display carries its own license and required attribution — ODbL (OSM
|
||||
* datacenters/dams, adsb.lol, Overpass roads), CC BY-NC-SA (TeleGeography
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS operators, OpenSky.
|
||||
|
||||
@@ -30,7 +30,7 @@ export const AIRCRAFT_BRACKET_ALPHA_FLOOR = 0.35;
|
||||
* stays a pure policy module with no Cesium dependency; detectionPolicy.test.mjs
|
||||
* imports the real constant and pins the two together so they cannot drift.
|
||||
*
|
||||
* It MOVES WITH THE DEFAULT (0.05 → 0.03 → 0.01; final value 2026-08-24).
|
||||
* It MOVES WITH THE DEFAULT (0.05 → 0.03 → 0.01; owner final lock 2026-08-24).
|
||||
* That pin is the tripwire
|
||||
* for exactly this change, and the decision it forces is which of two things the
|
||||
* approval attaches to: the bracket BRIGHTNESS, or the slider POSITION. It is
|
||||
|
||||
@@ -36,7 +36,7 @@ test('the bracket floor anchor mirrors the real keyhole default it is calibrated
|
||||
|
||||
test('the default OUTSIDE setting reproduces the approved 0.35 floor exactly', () => {
|
||||
// Byte-identical at the default, at every keyhole alpha, with the setting
|
||||
// passed explicitly and with it omitted. This is the accepted look; only
|
||||
// passed explicitly and with it omitted. The owner approved this look; only
|
||||
// the off-default range is allowed to change.
|
||||
assert.equal(aircraftBracketAlphaFloor(KEYHOLE_OUTSIDE_OPACITY_DEFAULT), AIRCRAFT_BRACKET_ALPHA_FLOOR);
|
||||
for (const alpha of [0.01, 0.05, 0.2, 0.34, 0.35, 0.36, 0.7, 1]) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 2. Free-form name/description text ("5TH ST / WEST AVE", "N LAMAR BLVD") is
|
||||
* full of STREET names that merely contain a cardinal word. Reading a bare
|
||||
* "West" there as a facing direction mis-orients the camera with false
|
||||
* confidence (59 of ~1000 Austin cameras hit this — field review
|
||||
* confidence (59 of ~1000 Austin cameras hit this — owner adversarial review
|
||||
* 2026-07-04). There, only explicit travel forms ("WESTBOUND"/"WB") count —
|
||||
* leave `allowBare=false` (the default).
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// directionToHeading — two matching modes. The regression that motivated the
|
||||
// split (adversarial field review, 2026-07-04): bare cardinal words were
|
||||
// split (owner adversarial review, 2026-07-04): bare cardinal words were
|
||||
// matched in free-form Austin camera names, so a street like "5TH ST / WEST
|
||||
// AVE" was mis-read as a west-facing camera with false high confidence.
|
||||
import { test } from 'node:test';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/fireAnchors.test.mjs — DEM ground anchors for rendered FIRMS
|
||||
// detections (field finding 2026-07-21: close-zoom fire dots read as
|
||||
// detections (owner field finding 2026-07-21: close-zoom fire dots read as
|
||||
// buried under high terrain because anchors sat at ellipsoid height 0).
|
||||
//
|
||||
// Locks the module's two jobs:
|
||||
|
||||
@@ -85,7 +85,7 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
});
|
||||
|
||||
// Field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// ground floor is warm — and the cached per-fire position must re-anchor when
|
||||
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
|
||||
// from every other test in this file (module caches persist across tests).
|
||||
|
||||
@@ -320,7 +320,6 @@ export function createFirmsHeatmapLayer({
|
||||
lastUpdate: _lastUpdate,
|
||||
loading: _loading,
|
||||
stale: _stale,
|
||||
keyRequired: _keyRequired,
|
||||
error: _keyRequired ? 'KEY REQUIRED' : (_stale ? staleText : _error),
|
||||
loadingLabel,
|
||||
};
|
||||
|
||||
+23
-29
@@ -17,7 +17,7 @@
|
||||
*
|
||||
* Press Escape or click empty space to deselect a tracked flight — the camera
|
||||
* is released IN PLACE (no flyTo), so the user keeps the context they were
|
||||
* looking at (product rule 2026-07-02).
|
||||
* looking at (owner decision 2026-07-02).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
|
||||
@@ -100,14 +100,14 @@ const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
|
||||
const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
// --- Ground traffic (product change 2026-07-03: "absolutely we should see planes
|
||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||
// taxiing and landing") -----------------------------------------------------------
|
||||
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
||||
// silhouette + rotation pipeline, clickable/trackable/detectable, sticky metadata
|
||||
// updating normally. Landing/takeoff is a TRANSITION — the on_ground flip restyles
|
||||
// the existing billboard in place, never a removal. Ground planes draw no trails
|
||||
// and are excluded from the ambient enrichment sweep (click-to-enrich still
|
||||
// works). In 3D mode they take model slots like airborne planes (product rule
|
||||
// works). In 3D mode they take model slots like airborne planes (owner decision
|
||||
// 2026-07-03 — no air/ground distinction), placed by the one-shot ground snap
|
||||
// (see _modelDisplayPosition).
|
||||
//
|
||||
@@ -121,7 +121,7 @@ const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||
const GROUND_SCALE = 0.8;
|
||||
|
||||
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
|
||||
* Ground traffic gets NO special tint (validated behavior 2026-07-03 field test). */
|
||||
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
||||
function _fleetBillboardColor(icao24) {
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ function _fleetBillboardScale(icao24, klass) {
|
||||
return (CLASS_SCALE_2D[klass] || 1) * (_flightData.get(icao24)?.onGround ? GROUND_SCALE : 1);
|
||||
}
|
||||
|
||||
/** Depth-test policy for aircraft billboards. Round 5 (product invariant
|
||||
/** Depth-test policy for aircraft billboards. Round 5 (owner directive
|
||||
* 2026-07-06: "I just want the planes and their lines to ALWAYS be
|
||||
* visible... evenly applied"): EVERY contact renders depth-test-free at
|
||||
* every distance — grounded, low, and airborne alike. The photoreal mesh
|
||||
@@ -157,7 +157,7 @@ const MODEL_MIN_PX = 24; // floor so distant models stay visible WITHOUT
|
||||
// min-pixel blob (was 54 — far planes at the All radius became white
|
||||
// star-bursts); ~matches the 2D icon size so the model↔billboard read is consistent
|
||||
const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff
|
||||
export const TRACKED_MODEL_MAX_PX = 200; // selected close-range tracked-target feel
|
||||
export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel
|
||||
const MODEL_NATIVE_RADIUS_M = 34.41;
|
||||
const MODEL_SCALE = 1; // airplane.glb is transform-applied and baked to real-world meters
|
||||
// Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost.
|
||||
@@ -246,7 +246,7 @@ const _modelGen = new Map();
|
||||
/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle
|
||||
* against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */
|
||||
let _modelEpoch = 0;
|
||||
/** DEFAULT-ON in PROXIMITY (product invariant 2026-08-22). A fresh boot never runs
|
||||
/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs
|
||||
* layer-state restoration, so this initializer — not the codec — is what the app
|
||||
* actually starts with; it must stay in lockstep with the `models3d` default in
|
||||
* `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would
|
||||
@@ -457,7 +457,7 @@ const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
);
|
||||
|
||||
function _normalBillboardScaleByDistance() {
|
||||
// Preserve the established close-range 3× scale. Any smaller user-visible
|
||||
// Preserve the established close-range 3× scale. Any smaller owner-visible
|
||||
// default belongs in a separate evidence-backed proposal.
|
||||
return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5);
|
||||
}
|
||||
@@ -599,7 +599,7 @@ let _trailBackfillToken = 0;
|
||||
const RENDER_DELAY_SEC = 30;
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
const MISSING_POLL_LIMIT = 3;
|
||||
// --- Landed-plane fast cull (field report 2026-07-02: "phantom" planes
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS
|
||||
// the actual landing, so a landed plane's last airborne-classified fixes show
|
||||
// it low + slow on the runway; when such a plane then drops out of the poll,
|
||||
@@ -652,7 +652,8 @@ function _approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
function _likelyLanded(icao24) {
|
||||
const info = _flightData.get(icao24);
|
||||
if (!info) return false;
|
||||
// Round 7: the fast cull only applies to contacts that were AIRBORNE
|
||||
// Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes
|
||||
// never heal"): the fast cull only applies to contacts that were AIRBORNE
|
||||
// this session — its original target, the post-LANDING ghost. OpenSky's
|
||||
// ground coverage flaps constantly, so fast-culling every grounded contact
|
||||
// put parked planes in an evict/re-enter churn: each re-entry was a
|
||||
@@ -1579,14 +1580,14 @@ function _noteTrackedModelLoadFailure(url, err) {
|
||||
|
||||
/**
|
||||
* The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven
|
||||
* (product invariant 2026-08-19). Unlike the fleet, this does NOT consult the
|
||||
* (owner directive 2026-08-19). Unlike the fleet, this does NOT consult the
|
||||
* DISPLAY-rail `models3d` toggle: the selected contact is a single model, it is
|
||||
* what the camera is pointed at, and zooming in on a target should resolve it
|
||||
* into an aircraft without the operator arming anything. The toggle keeps
|
||||
* owning the FLEET (`_modelRegimeActive`), which is the draw-call budget.
|
||||
*
|
||||
* Thresholds + hysteresis live in trackedModelRegime.js: enter at
|
||||
* TRACKED_MODEL_ENTER_ALT_M (150_000 m — the playtested swap distance,
|
||||
* TRACKED_MODEL_ENTER_ALT_M (150_000 m — the owner's playtested swap distance,
|
||||
* deliberately NEARER than the fleet's 800 km ceiling this used to inherit),
|
||||
* hand back only above TRACKED_MODEL_EXIT_ALT_M, so orbiting AT the boundary
|
||||
* cannot flap billboard↔model. See that module's header for why the tracked
|
||||
@@ -1897,7 +1898,7 @@ const FLOOR_EASE_EPSILON_M = 0.02;
|
||||
/**
|
||||
* The floor to stand a grounded contact on while its own cell is unresolved.
|
||||
*
|
||||
* Product invariant (2026-08-21, after a Re:Earth outage buried a parked contact
|
||||
* Owner directive (2026-08-21, after a Re:Earth outage buried a parked contact
|
||||
* at a Texas field): "hold the last known altitude until a fresh one comes in.
|
||||
* Never render otherwise." Two tiers, strongest first:
|
||||
* own — a floor this contact's OWN cell resolved to while it stood there.
|
||||
@@ -1976,7 +1977,7 @@ function _dropHeldFloor(state) {
|
||||
/** @constant {number} How long a retired hold stays usable as a rehydration
|
||||
* seed — three poll intervals.
|
||||
*
|
||||
* Deleting the state outright was the first cut, and a field observation found
|
||||
* Deleting the state outright was the first cut, and an owner sighting found
|
||||
* what that costs: VIR138M at JFK, 45 kt down the runway, "clearly on good
|
||||
* ground, then suddenly popped below the ground, then popped back up".
|
||||
* OpenSky's `on_ground` flag is not clean through a rotation — it flaps — and
|
||||
@@ -2129,7 +2130,7 @@ function _floorGroundedDisplayPosition(icao24, info, pos, modelOwnsVisual, nowMs
|
||||
next.heldActive = Number.isFinite(effective);
|
||||
}
|
||||
// The floor moved DOWN under a contact that was standing on a BORROWED one.
|
||||
// Dropping it by that difference in a single tick is the snap product behavior requires
|
||||
// Dropping it by that difference in a single tick is the snap the owner asked
|
||||
// not to have, so approach it instead. Two ways in, and both need it:
|
||||
// - the real floor arrives below the hold (releasing the hold);
|
||||
// - a re-probe finds a LOWER neighbour than the one being held, which the
|
||||
@@ -2309,12 +2310,6 @@ export function _clearDisplayFloorStateForTest() {
|
||||
_displayFloorState.clear();
|
||||
}
|
||||
|
||||
/** Test hook: drops cached model ground snaps so a browser-harness scenario
|
||||
* cannot inherit another scenario's per-contact measurement. */
|
||||
export function _clearGroundSnapStateForTest() {
|
||||
_groundSnap.clear();
|
||||
}
|
||||
|
||||
/** Spec identity for a LOADED model: URL and scale together (same-URL classes
|
||||
* differ by scale — airliner vs quadjet both ship airplane.glb). */
|
||||
const _specKeyFor = (klass) => {
|
||||
@@ -2341,7 +2336,7 @@ function _syncModelToClass(icao24) {
|
||||
}
|
||||
}
|
||||
|
||||
/** IR hot-target mode (field test 2026-08-16): the NVG/FLIR post-styles
|
||||
/** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles
|
||||
* map LUMINANCE, so mid-gray textured models read cold and vanish into
|
||||
* terrain. While a boost style is active every model renders flat white
|
||||
* (hottest); per-spec color/tint restores on style exit. Driven by ui.js
|
||||
@@ -2679,7 +2674,7 @@ function _fleetTick() {
|
||||
// not just at the handoff below, or accumulated conversions would starve
|
||||
// ordinary contacts of 3D models. (The handoff guard stays as defence.)
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (product rule
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03: "3D mode is respected regardless of whether a plane is on the
|
||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
@@ -2782,7 +2777,7 @@ function _fleetTick() {
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
});
|
||||
_billboardLimbScale.set(bb, treatment.factors.scale);
|
||||
// Two-tier glyph raster (field test 2026-08-16): the billboard atlas
|
||||
// Two-tier glyph raster (owner playtest 2026-08-16): the billboard atlas
|
||||
// has no mipmaps, so no single texture stays crisp across the ~25–150
|
||||
// device-px range scaleByDistance produces. Swap between the 64 px fleet
|
||||
// raster and the 192 px close raster on the billboard's ACTUAL on-screen
|
||||
@@ -3079,7 +3074,7 @@ async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
// and floor every waypoint at it so low baro segments never dive below the
|
||||
// mesh; a no-baro waypoint (predominantly taxi/ground segments in /tracks)
|
||||
// sits ON the surface when the floor is known.
|
||||
// Round-2 fix: the
|
||||
// Round-2 fix (owner: "trails suddenly much shorter / not loading"): the
|
||||
// resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth
|
||||
// lookup across a long path could stall the paint for seconds-to-timeout.
|
||||
// Paint with whatever cells are warm; the resolve keeps filling the cache
|
||||
@@ -3891,7 +3886,6 @@ const flightsLayer = {
|
||||
// Browser-harness seam: isolates synthetic display-floor scenarios without
|
||||
// changing any production lifecycle or cache policy.
|
||||
_clearDisplayFloorStateForTest,
|
||||
_clearGroundSnapStateForTest,
|
||||
/** @type {number} Polling interval (ms) between update() calls */
|
||||
updateInterval: 30000,
|
||||
|
||||
@@ -4053,7 +4047,7 @@ const flightsLayer = {
|
||||
* reconcile them with the billboard collection.
|
||||
*
|
||||
* Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors
|
||||
* with exponential-ish backoff. On success, adds, updates, or removes
|
||||
* with exponential-ish backoff. On success, adds/updates/removes
|
||||
* billboards and position history, triggers lerp blending for the
|
||||
* tracked aircraft, and updates its label text.
|
||||
*
|
||||
@@ -4247,7 +4241,7 @@ const flightsLayer = {
|
||||
// on_ground surface prior: ONLY synchronous warm-cache reads here —
|
||||
// never a per-aircraft network fetch inside the poll loop (see the
|
||||
// batch resolve call below, which fills this cache for NEXT poll). A
|
||||
// Round 5 SIMPLIFICATION (product invariant: one floor, evenly applied):
|
||||
// Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied):
|
||||
// the grounded surface is the round-4 choke point and nothing else —
|
||||
// rendered-mesh cell first, real (never fallback-poisoned) DEM cell
|
||||
// second. The old exact-5-decimal warm chain is GONE: it minted a new
|
||||
@@ -4553,7 +4547,7 @@ const flightsLayer = {
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (product rule 2026-07-02: no overview flyTo).
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
if (icao24 === _trackedIcao) {
|
||||
_clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
@@ -885,13 +885,13 @@ test('display floor: the cached output is dropped when the floor changes under i
|
||||
|
||||
// --- F8: hold the last known floor through a floor-data gap ----------------
|
||||
//
|
||||
// Field incident 2026-08-21: four `[terrain-heights-proxy] refresh incomplete`
|
||||
// Owner incident 2026-08-21: four `[terrain-heights-proxy] refresh incomplete`
|
||||
// events in a row (Re:Earth timing out), and a parked contact at a Texas field
|
||||
// popped BELOW the photoreal mesh for a few seconds. A cold cell used to mean
|
||||
// "no clamp", which is only safe if the un-clamped height is a real reading —
|
||||
// and for a grounded contact reporting no altitude it is the geoid, tens of
|
||||
// metres under the mesh inland. The product must hold the last known altitude
|
||||
// until fresh floor evidence arrives.
|
||||
// metres under the mesh inland. Owner: "hold the last known altitude until a
|
||||
// fresh one comes in. Never render otherwise."
|
||||
|
||||
test('display floor: a cold cell HOLDS the last floor that resolved for this contact', () => {
|
||||
_clearDisplayFloorStateForTest();
|
||||
@@ -1052,7 +1052,7 @@ test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the run
|
||||
// from the resolved surface to baro + geoid N — which at a sea-level field IS
|
||||
// the geoid. Deleting the hold on the airborne poll made that switch visible:
|
||||
// the contact came back grounded with no prior, outrunning its own floor
|
||||
// cells at 23 m/s, and sat under the runway (field observation, VIR138M).
|
||||
// cells at 23 m/s, and sat under the runway (owner sighting, VIR138M).
|
||||
const GROUND = -28.5, GEOID = -32.5, LON = -73.78;
|
||||
reportMeshFloorCell(40.64, LON, GROUND); // only the cell it STARTED on is warm
|
||||
let lat = 40.64;
|
||||
|
||||
+5
-4
@@ -5,14 +5,15 @@
|
||||
// Caltrans/TfL camera priors) give ORTHOMETRIC height (H, "height above mean
|
||||
// sea level"). N is the local geoid undulation — the gap between the WGS84
|
||||
// ellipsoid and the geoid (~mean sea level) surface, ranging roughly
|
||||
// -106..+85 m worldwide. See docs/CURRENT-STATE.md.
|
||||
// -106..+85 m worldwide. See docs/plans/2026-07-05-entity-height-datum-fix.md.
|
||||
//
|
||||
// The implementation uses `egm96-universal` (npm, MIT, embeds the NGA
|
||||
// Decision rule (task brief): try `egm96-universal` (npm, MIT, embeds the NGA
|
||||
// EGM96 15' grid) as a lazy dynamic import so its ~2.7 MB grid data-chunk
|
||||
// never lands in the eager Vite bundle. Only fall back to vendoring the NGA
|
||||
// grid ourselves if the package fails tests, isn't browser-safe, or bloats
|
||||
// the eager bundle. It passed the browser-safety, accuracy, and bundle checks,
|
||||
// so this file is a thin wrapper around it — no vendored fallback is needed.
|
||||
// the eager bundle. `egm96-universal` passed all three checks (see the
|
||||
// task report), so this file is a thin wrapper around it — no vendored
|
||||
// fallback was needed.
|
||||
//
|
||||
// egm96-universal's `meanSeaLevel(lat, lon)` already returns exactly N in
|
||||
// metres (relative to WGS84 ellipsoid) with internal longitude
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/data/geoid.test.mjs — EGM96 geoid-undulation lookup.
|
||||
// src/data/geoid.test.mjs — EGM96 geoid-undulation lookup (docs/plans/2026-07-05-entity-height-datum-fix.md Task 1).
|
||||
//
|
||||
// Locks the module's public interface (later tasks — aircraft altitude
|
||||
// correction, CCTV terrain fallback — call this verbatim):
|
||||
@@ -7,7 +7,7 @@
|
||||
// orthometricToEllipsoidal(hMslM, latDeg, lonDeg): number hMslM + N
|
||||
//
|
||||
// Tolerance is loose (±2.5 m) by design: the bundled grid is EGM96 while the
|
||||
// reference values are Re:Earth's EGM2008 — the two
|
||||
// plan's "Verified facts" reference values are Re:Earth's EGM2008 — the two
|
||||
// models differ by up to ~1 m, and the brief's own tolerance absorbs that
|
||||
// spread rather than asserting exact agreement.
|
||||
import { test } from 'node:test';
|
||||
@@ -100,7 +100,7 @@ test('the reported SFO cockpit OSD height turns into a small positive MSL number
|
||||
await ensureGeoidReady();
|
||||
const n = geoidHeight(SFO.lat, SFO.lon);
|
||||
assert.ok(n < -25 && n > -40, `SFO undulation should be strongly negative, got ${n}`);
|
||||
// The screenshot showed ALT: -15m ellipsoidal over the SFO deck.
|
||||
// The owner's screenshot: ALT: -15m ellipsoidal over the SFO deck.
|
||||
const displayed = ellipsoidalToMslDisplayM(-15, n);
|
||||
assert.ok(
|
||||
displayed > 10 && displayed < 25,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
|
||||
// heights (field-test round 2026-07-06).
|
||||
//
|
||||
// Two field findings drove this module:
|
||||
// Two owner findings drove this module:
|
||||
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
|
||||
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
|
||||
// local relief).
|
||||
@@ -118,7 +118,7 @@ export const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
|
||||
*
|
||||
* An earlier cut leaned HIGH, reasoning from the locked "never below the
|
||||
* visible surface" principle. That principle is about a contact's OWN measured
|
||||
* ground; applied to a BORROWED cell it inverts, and a field test found
|
||||
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
|
||||
* why — planes floating in midair at terminal gates. The two errors are not
|
||||
* symmetric:
|
||||
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
|
||||
@@ -376,7 +376,7 @@ export function allocateCorridorCells(
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Mesh-floor cells (round 4, validated design) ---------------------
|
||||
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
|
||||
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
|
||||
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
|
||||
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
|
||||
|
||||
@@ -597,7 +597,7 @@ test('neighborFloorM takes the apron, not the roof, at a structure edge', () =>
|
||||
reportMeshFloorCell(30.201, -97.66, 120); // the apron
|
||||
reportMeshFloorCell(30.199, -97.66, 205); // the terminal roof next door
|
||||
// A parked contact is on the apron; it is never on the roof. Leaning high
|
||||
// here is what put planes in midair at gates during the field test.
|
||||
// here is what put planes in midair at gates during the owner playtest.
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), 120);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Mapped-installation proxy persistence (field test 2026-08-18: "search
|
||||
// Mapped-installation proxy persistence (owner playtest 2026-08-18: "search
|
||||
// nearby sites" was slow because every look around paid a live Overpass round
|
||||
// trip, and the 5-minute memory tier died with the dev server).
|
||||
//
|
||||
|
||||
@@ -183,7 +183,7 @@ function integerOption(key, token, defaultValue) {
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
flights: Object.freeze([
|
||||
// Product invariant 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
||||
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
||||
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
|
||||
// materialize once the camera is close enough and only for the nearest
|
||||
// contacts in view — so "on" costs nothing at globe scale, and an operator
|
||||
|
||||
@@ -332,7 +332,7 @@ test('compact URL omits absent-meaning option state and still resolves to it', (
|
||||
});
|
||||
|
||||
test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, and the rail agree', async () => {
|
||||
// Product invariant 2026-08-22: the DISPLAY-rail 3D toggle defaults ON with mode
|
||||
// Owner directive 2026-08-22: the DISPLAY-rail 3D toggle defaults ON with mode
|
||||
// `proximity`, because proximity is itself the budget — models materialize only
|
||||
// below the fleet altitude ceiling and only for the nearest MODEL_MAX in view,
|
||||
// so "on" costs nothing at globe scale and `all` stays a deliberate opt-in.
|
||||
|
||||
@@ -51,7 +51,7 @@ const DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the validated local-infrastructure card copy.
|
||||
* Build the owner-approved local-infrastructure card copy.
|
||||
* @param {object} properties Unwrapped GeoJSON feature properties.
|
||||
* @param {string} layerId Local layer id.
|
||||
* @returns {{title:string,details:string[]}}
|
||||
@@ -317,7 +317,7 @@ export function createLocalGeoJsonLayer({
|
||||
* camera is parked. One timer for the whole layer (not per record) — the
|
||||
* retry pass walks every record anyway. (perf rebase 2026-08-17)
|
||||
*
|
||||
* Two gates keep this from becoming an idle leak (second review):
|
||||
* Two gates keep this from becoming an idle leak (review round 2):
|
||||
* - CAPABILITY: without `scene.sampleHeightSupported` the sample can never
|
||||
* succeed, so a timer here would re-arm on every requested frame,
|
||||
* forever. Records simply stay at ellipsoid height — exactly the
|
||||
|
||||
@@ -149,7 +149,7 @@ async function createRealLocalLayerHarness({
|
||||
};
|
||||
}
|
||||
|
||||
test('local infrastructure card copy uses the validated source fields', () => {
|
||||
test('local infrastructure card copy uses the owner-approved source fields', () => {
|
||||
assert.deepEqual(localInfrastructureOverlayCopy({
|
||||
tags: {
|
||||
name: 'DFW-1',
|
||||
@@ -740,7 +740,7 @@ test('disable cancels a pending ground-retry render', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── The retry must be able to STOP (second review) ───────────────────────────
|
||||
// ── The retry must be able to STOP (review round 2) ───────────────────────────
|
||||
//
|
||||
// The retry above arms itself off its own requested frame, so anything that
|
||||
// makes the sample permanently impossible turns it into a perpetual-motion
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_SAMPLES_PER_CALL = 40;
|
||||
/** @constant {number} Only cells within this range of the viewer subpoint are
|
||||
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
|
||||
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
|
||||
* one-shot latch made those permanent (follow-up round 5: grounded planes stuck
|
||||
* one-shot latch made those permanent (owner round 5: grounded planes stuck
|
||||
* at a uniform wrong height). */
|
||||
const MAX_SAMPLE_DIST_KM = 15;
|
||||
/** @constant {number} No sampling when the camera is above this height — the
|
||||
@@ -155,8 +155,9 @@ export function sampleMeshFloorCells(scene, points, { excludeObjects = [], viewe
|
||||
// the sample point: walk the tileset tree to the tile containing the
|
||||
// coordinate and require its `geometricError` to be below a threshold tied
|
||||
// to the accuracy the caller needs, before trusting the sample. That is
|
||||
// feasible and deliberately deferred until rendered-mesh sampling can be
|
||||
// validated consistently across all consumers.
|
||||
// feasible and deliberately deferred — see the P2 in
|
||||
// the project roadmap, which bundles it with the fly_route probe so
|
||||
// one post-launch pass settles rendered-mesh sampling everywhere.
|
||||
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations'];
|
||||
const DEPENDENCIES = [...AIRCRAFT_DEPENDENCIES, ...DEFERRED_DEPENDENCIES];
|
||||
const AWARENESS_REFRESH_MS = 750;
|
||||
/** @constant {number} Refresh cadence while the camera pose is CHANGING.
|
||||
* Field test 2026-08-18: the Contacts direction arrows and card readouts
|
||||
* Owner playtest 2026-08-18: the Contacts direction arrows and card readouts
|
||||
* "feel sluggish when you look around" — at the parked 750 ms cadence the
|
||||
* arrows lag the view by up to three quarters of a second. */
|
||||
const AWARENESS_MOTION_REFRESH_MS = 175;
|
||||
@@ -444,7 +444,7 @@ export function buildAwarenessContextSnapshot(results, navigation = {}, { subjec
|
||||
* They used to be separate. The panel read live billboard positions through
|
||||
* `getNearby` with a 20 000 cap; the analyst re-derived its own answer from
|
||||
* last-fix coordinates over a 2 000-record slice. Same question, same centre,
|
||||
* two numbers — and in the live trial the spoken answer (15) and the panel
|
||||
* two numbers — and in the owner's trial the spoken answer (15) and the panel
|
||||
* (111) disagreed badly enough that the model narrated the difference away.
|
||||
* Routing both through here makes them the same number BY CONSTRUCTION, so
|
||||
* they cannot drift again.
|
||||
|
||||
@@ -1080,7 +1080,7 @@ test('awareness clears are scoped to the selected source layer', () => {
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// BEGIN Contact-readout presence block.
|
||||
// BEGIN Contact-readout presence block — fix/context-panel-next-subjects.
|
||||
// Integrators: this whole delimited block belongs to the Contact-panel
|
||||
// CONTACT LOST work. Keep it intact and keep any concurrent branch's own
|
||||
// additions at the END of the file, so the two never collide.
|
||||
@@ -1483,7 +1483,7 @@ test('production eviction sites actually tag their clears', () => {
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// END Contact-readout presence block.
|
||||
// END Contact-readout presence block — fix/context-panel-next-subjects.
|
||||
// ===========================================================================
|
||||
|
||||
test('cockpit blocks only non-aircraft Context camera flights', () => {
|
||||
|
||||
+16
-16
@@ -95,7 +95,7 @@ const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
||||
/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */
|
||||
const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
||||
|
||||
// --- Ground traffic (product change 2026-07-03; mirror of flights.js) ---------------
|
||||
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
|
||||
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
|
||||
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
||||
// fallback: same silhouette + rotation pipeline, clickable/trackable/detectable,
|
||||
@@ -105,7 +105,7 @@ const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
||||
// decision 2026-07-03 — no air/ground distinction), placed by the one-shot
|
||||
// ground snap (see _modelDisplayPosition).
|
||||
//
|
||||
// TINT: full-strength amber, same as airborne (validated behavior 2026-07-03 field
|
||||
// TINT: full-strength amber, same as airborne (owner verdict 2026-07-03 field
|
||||
// test: the day-1 slate-gray 50%-alpha muted tint was unreadable — "in NYC I can
|
||||
// barely see them"). "On the ground" reads from the ×0.8 scale + missing trail;
|
||||
// "feed-dropped, coasting" stays the 45%-alpha stale fade.
|
||||
@@ -113,7 +113,7 @@ const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
||||
const GROUND_SCALE = 0.8;
|
||||
|
||||
/** Depth-test policy for aircraft billboards (mirror of flights.js — see the
|
||||
* full rationale there). Round 5 (product invariant 2026-07-06): EVERY contact
|
||||
* full rationale there). Round 5 (owner directive 2026-07-06): EVERY contact
|
||||
* renders depth-test-free at every distance — a uniform always-visible rule;
|
||||
* the fleet tick's horizon occluder still removes far-side contacts. */
|
||||
function _groundDepthDistance() {
|
||||
@@ -131,7 +131,7 @@ const JET_MODEL_URL = '/models/jet.glb';
|
||||
const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger)
|
||||
const MODEL_MIN_PX = 24; // floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size)
|
||||
const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff
|
||||
export const TRACKED_MODEL_MAX_PX = 200; // selected close-range tracked-target feel
|
||||
export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel
|
||||
const MODEL_NATIVE_RADIUS_M = 29.83;
|
||||
// jet.glb is transform-applied at real-world scale — native bounding radius
|
||||
// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching
|
||||
@@ -164,14 +164,14 @@ const PLANE_MODEL_URL = '/models/airplane.glb';
|
||||
const PLANE_MODEL_SCALE = 1;
|
||||
const PLANE_NATIVE_RADIUS_M = 34.41;
|
||||
const PLANE_BELLY_OFFSET_NATIVE = 6.719;
|
||||
/** Per-class model spec for THIS layer (2026-08-16, field test ask:
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
* amber). Real Hangar GLBs serve the classes they cover (meters, nose −X →
|
||||
* 180° offset); airliner/quadjet/glider get the shared 747 silhouette
|
||||
* (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets);
|
||||
* fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays
|
||||
* dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR,
|
||||
* and the tint must dominate any livery. Specs are
|
||||
* and the tint must dominate any livery (owner: "stays yellow"). Specs are
|
||||
* static per class — memoized (the fleet pass asks at 12 Hz per model). */
|
||||
const _specCache = new Map();
|
||||
function _modelSpec(klass) {
|
||||
@@ -213,7 +213,7 @@ const _modelGen = new Map();
|
||||
/** Lifecycle epoch; bumped on destroy so an in-flight load from a PREVIOUS init can't settle
|
||||
* against a new lifecycle's globals (which destroy cleared). Captured by _ensureModel. */
|
||||
let _modelEpoch = 0;
|
||||
/** DEFAULT-ON in PROXIMITY (product invariant 2026-08-22). A fresh boot never runs
|
||||
/** DEFAULT-ON in PROXIMITY (owner directive 2026-08-22). A fresh boot never runs
|
||||
* layer-state restoration, so this initializer — not the codec — is what the app
|
||||
* actually starts with; it must stay in lockstep with the `models3d` default in
|
||||
* `layerState.js` and `this._models3dEnabled` in ui.js, or the DISPLAY rail would
|
||||
@@ -384,7 +384,7 @@ const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
|
||||
function _normalBillboardScaleByDistance() {
|
||||
// Match commercial flights and preserve the established close-range 3×
|
||||
// default. Smaller user-visible scaling must be proposed separately.
|
||||
// default. Smaller owner-visible scaling must be proposed separately.
|
||||
return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5);
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ let _trailBackfillToken = 0;
|
||||
const RENDER_DELAY_SEC = 15;
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */
|
||||
const MISSING_POLL_LIMIT = 3;
|
||||
// --- Landed-plane fast cull (mirror of flights.js; field report
|
||||
// --- Landed-plane fast cull (mirror of flights.js; owner field report
|
||||
// 2026-07-02: "phantom" planes lingered ~2 min at airports after touchdown).
|
||||
// The feed's ground flag lags the actual landing, so a landed plane's last
|
||||
// airborne fixes show it low + slow on the runway; when it then drops out of
|
||||
@@ -1357,11 +1357,11 @@ function _noteTrackedModelLoadFailure(url, err) {
|
||||
|
||||
/**
|
||||
* The TRACKED aircraft's own model regime — DEFAULT-ON, camera-distance driven
|
||||
* (product invariant 2026-08-19). Mirror of flights.js: this does NOT consult the
|
||||
* (owner directive 2026-08-19). Mirror of flights.js: this does NOT consult the
|
||||
* DISPLAY-rail `models3d` toggle, which keeps owning the FLEET
|
||||
* (`_modelRegimeActive`) and its draw-call budget. Thresholds + hysteresis live
|
||||
* in trackedModelRegime.js — enter at TRACKED_MODEL_ENTER_ALT_M (150_000 m, the
|
||||
* playtested swap distance, deliberately NEARER than the fleet's 800 km
|
||||
* owner's playtested swap distance, deliberately NEARER than the fleet's 800 km
|
||||
* ceiling this used to inherit), hand back only above
|
||||
* TRACKED_MODEL_EXIT_ALT_M so a boundary orbit cannot flap billboard↔model.
|
||||
* First-person means your own airframe is not drawn in cockpit — the eye sits
|
||||
@@ -1839,7 +1839,7 @@ function _fleetTick() {
|
||||
// 3D-model eligibility: by DISTANCE (mode's add/keep band) with ON-SCREEN PRIORITY under the cap —
|
||||
// mirror of flights.js. FOUR visible-first passes: (1) KEEP on-screen modeled; (2) ADD on-screen
|
||||
// new in add radius; (3) KEEP off-screen modeled; (4) ADD off-screen new with leftover slots. KEEP
|
||||
// is split by frustum so an off-screen retained model can't starve an on-screen plane (review).
|
||||
// is split by frustum so an off-screen retained model can't starve an on-screen plane (review finding).
|
||||
let modelEligible = null;
|
||||
if (useModels) {
|
||||
const cap = _modelCap();
|
||||
@@ -1856,7 +1856,7 @@ function _fleetTick() {
|
||||
// it must not occupy a CAP SLOT either (mirror of flights.js) — excluded
|
||||
// at selection time, not just at the handoff below.
|
||||
if (isTr3b(icao)) continue;
|
||||
// Ground planes compete for model slots like everyone else (product rule
|
||||
// Ground planes compete for model slots like everyone else (owner decision
|
||||
// 2026-07-03, mirror of flights.js — no air/ground distinction; grounded
|
||||
// placement is handled by the one-shot ground snap in _modelDisplayPosition).
|
||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position);
|
||||
@@ -2235,7 +2235,7 @@ async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
// 'ground'/null point sits ON the local surface — the old fixed 50 m
|
||||
// sentinel rendered ~1.5 km underground at Kirtland AFB (field ~1590 m
|
||||
// ellipsoidal) and dragged the whole pattern-work loop with it.
|
||||
// Round-2 fix: the
|
||||
// Round-2 fix (owner: "trails suddenly much shorter / not loading"): the
|
||||
// resolve is BOUNDED (≤1.2 s), not a blocking await — a cold Re:Earth
|
||||
// lookup across a long path could stall the paint for seconds-to-timeout.
|
||||
// Paint with whatever cells are warm; the resolve keeps filling the cache
|
||||
@@ -3193,7 +3193,7 @@ const militaryFlightsLayer = {
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (product rule 2026-07-02: no overview flyTo).
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
if (icao24 === _trackedIcao) {
|
||||
_clearTracking(false, { evicted: true });
|
||||
}
|
||||
@@ -3549,7 +3549,7 @@ const militaryFlightsLayer = {
|
||||
// card, in the analyst's answer, and in the Contacts list. Matching only
|
||||
// callsigns meant "follow 6606" — and the analyst → track_entity handoff
|
||||
// the tool instructions prescribe — answered "nothing matched" for the
|
||||
// very identity the app had just shown (field session 2026-08-21,
|
||||
// very identity the app had just shown (owner field session 2026-08-21,
|
||||
// 23:48: three failed track_entity retries before a fallback stuck).
|
||||
// Ranking is shared with the flights layer (contactMatch.js) so the two
|
||||
// cannot disagree, and it is strictly tiered so a registration can never
|
||||
|
||||
@@ -449,7 +449,7 @@ test('real military track path creates no native label and publishes every cache
|
||||
* and then hand that identity to track_entity. The analyst's `id` is a DISPLAY
|
||||
* label — callsign, else registration, else hex — while the lookup matched
|
||||
* callsigns and hex only, so a callsign-less contact came back as its tail
|
||||
* number and "Nothing matched" (field session 2026-08-21, 23:48: three
|
||||
* number and "Nothing matched" (owner field session 2026-08-21, 23:48: three
|
||||
* failed retries before a fallback stuck).
|
||||
*/
|
||||
test('a callsign-less contact is findable by the tail number the app displays', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const CLASS_BY_MILITARY_TAG = {
|
||||
};
|
||||
|
||||
/**
|
||||
* How an UNNAMED feature reads on the map (field test 2026-08-18: the old
|
||||
* How an UNNAMED feature reads on the map (owner playtest 2026-08-18: the old
|
||||
* fallback surfaced "range (10981656305)" — an OSM primary key shown to a human
|
||||
* as if it were a place name).
|
||||
*
|
||||
|
||||
@@ -309,7 +309,7 @@ function renderRecords() {
|
||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
||||
* and lands seconds later, and without this the records it covers stay pinned
|
||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (field test
|
||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
||||
* 2026-08-18: "orange dots at the bottom").
|
||||
*
|
||||
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
||||
|
||||
@@ -763,8 +763,8 @@ test('the trail head grows continuously across the envelope, never in one step',
|
||||
// heading then rotated into world space, putting the trail out to one side and
|
||||
// flipping which side as the course changed.
|
||||
//
|
||||
// A single-heading harness cannot catch this: at one heading a wrongly-framed
|
||||
// offset can coincidentally point
|
||||
// A single-heading harness cannot catch this, which is exactly how it reached
|
||||
// the owner: at one heading a wrongly-framed offset can coincidentally point
|
||||
// aft. So this sweeps headings and every shipped asset, and asserts the
|
||||
// property directly — the anchor lies in the model's longitudinal/vertical
|
||||
// plane, with NO lateral component.
|
||||
|
||||
@@ -88,7 +88,7 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
|
||||
* track only (chord weight 0). ≈30 kt. */
|
||||
export const COURSE_TRACK_ONLY_MPS = 15.4;
|
||||
/** At/above this displayed ground speed the course source is the chord only
|
||||
* (weight 1) — the field-validated regime; behavior is unchanged there.
|
||||
* (weight 1) — the regime the owner field-approved; behavior unchanged there.
|
||||
* ≈50 kt. */
|
||||
export const COURSE_CHORD_ONLY_MPS = 25.7;
|
||||
/** Below this displayed speed neither chord nor reported track means anything
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* neighborhood boundaries that OSM tags as label-nodes-only (Chinatown, the Marina, the
|
||||
* Mission, …). It sits AHEAD of the live-Overpass / synthesis path in the resolver, so
|
||||
* covered neighborhoods resolve instantly to a REAL boundary with no network dependency
|
||||
* because the live Overpass path is slow and inconsistent for neighborhoods.
|
||||
* (the live-Overpass path is slow/flaky for neighborhoods — see docs/field-test-2-analysis.md).
|
||||
*
|
||||
* Source-agnostic: each city is a `{name, geometry}` GeoJSON file in
|
||||
* `local_data/neighborhoods/`. Swap the file (e.g. to public-domain DataSF) without
|
||||
|
||||
@@ -4,6 +4,7 @@ import createViteConfig, {
|
||||
adsbLolFallbackAnchor,
|
||||
coalesceProxyRequest,
|
||||
launchLibraryRequestHeaders,
|
||||
keylessGooglePlacesResponse,
|
||||
LL2_CACHE_TTL_MS,
|
||||
readResponseJsonCapped,
|
||||
regionalBriefHasAnySource,
|
||||
@@ -11,6 +12,18 @@ import createViteConfig, {
|
||||
validRegionalPoint,
|
||||
} from '../../vite.config.js';
|
||||
|
||||
test('missing Google place context is a quiet keyless capability, not a 503', () => {
|
||||
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
|
||||
statusCode: 200,
|
||||
payload: { configured: false, error: null, places: [] },
|
||||
});
|
||||
assert.deepEqual(keylessGooglePlacesResponse(' '), {
|
||||
statusCode: 200,
|
||||
payload: { configured: false, error: null, places: [] },
|
||||
});
|
||||
assert.equal(keylessGooglePlacesResponse('configured-key'), null);
|
||||
});
|
||||
|
||||
test('regional proxy rejects absent and blank coordinates instead of coercing them to zero', () => {
|
||||
assert.equal(validRegionalPoint(new URLSearchParams('longitude=12.5')), null);
|
||||
assert.equal(validRegionalPoint(new URLSearchParams('latitude=12.5')), null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/renderAltitude.js — pure priority-chain helper for aircraft render
|
||||
// altitude.
|
||||
// altitude (docs/plans/2026-07-05-entity-height-datum-fix.md Task 6).
|
||||
//
|
||||
// The globe needs ELLIPSOIDAL height (h = H + N). OpenSky's `geo_altitude`
|
||||
// (state-vector index 13) is already WGS84 geometric/ellipsoidal — the
|
||||
@@ -78,7 +78,7 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
|
||||
* contact's cells went cold, and this guess overwrote a height that had been
|
||||
* sitting correctly on the mesh — dropping it through the ground until the
|
||||
* proxy recovered. With `priorRenderM` present the caller's existing sticky
|
||||
* fallback holds that height instead, which is what product behavior requires for:
|
||||
* fallback holds that height instead, which is what the owner asked for:
|
||||
* "hold the last known altitude until a fresh one comes in."
|
||||
*
|
||||
* @param {object} params
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/data/renderAltitude.test.mjs
|
||||
// docs/plans/2026-07-05-entity-height-datum-fix.md Task 6.
|
||||
//
|
||||
// Locks pickRenderAltitudeM's priority chain — the SINGLE source of truth for
|
||||
// where a flights-layer aircraft (and its dead-reckoned/tracked-camera
|
||||
@@ -182,7 +183,7 @@ test('taxiing aircraft over nonzero terrain resolves every poll after the first
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// geoidSurfaceLastResortM — the geoid guess must never outrank what the
|
||||
// contact already knows (field incident 2026-08-21: a Re:Earth outage plus
|
||||
// contact already knows (owner incident 2026-08-21: a Re:Earth outage plus
|
||||
// this guess dropped a parked contact through the mesh at a Texas field).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
|
||||
test('a tracked docked cluster consolidates its companions onto one card', () => {
|
||||
// ISS and everything berthed to it are separate real tracks at one position,
|
||||
// so their ambient labels stack underneath the tracked card. Product decision:
|
||||
// so their ambient labels stack underneath the tracked card. Owner ruling:
|
||||
// consolidate them as secondary info on that card, and suppress only those
|
||||
// members — never unrelated satellites that merely happen to be nearby.
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
|
||||
@@ -30,7 +30,7 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
// Earth actually occupies, because the values BETWEEN "zero" and "the globe"
|
||||
// are the ones that fail quietly.
|
||||
const belowTheFloor = [
|
||||
// the regression probe: finite, non-zero, and 6,378 km underground. A bare
|
||||
// The adversarial probe: finite, non-zero, and 6,378 km underground. A bare
|
||||
// non-zero check accepts it, and the shipped path then reverse-geocodes
|
||||
// 0°, 0° as if the operator were looking at the Gulf of Guinea.
|
||||
new Cesium.Cartesian3(500, 0, 0),
|
||||
|
||||
@@ -54,8 +54,10 @@ export const CABLE_SWEEP_MOTION_EPSILON_M = 250;
|
||||
* layer alone cost ~9.5 ms/frame during camera motion. The depth cue this
|
||||
* trades away (photoreal tiles occluding label TEXT at low, city-level
|
||||
* cameras) matches the sibling dams/datacenters sources, which shipped
|
||||
* host-composited under the same rule; the anchor points/stems remain
|
||||
* Cesium-native and depth-tested.
|
||||
* host-composited under the same ruling; the anchor points/stems remain
|
||||
* Cesium-native and depth-tested. See
|
||||
* the world-overlay consolidation design notes ("Depth-testing
|
||||
* decision") for both dated decisions.
|
||||
*/
|
||||
export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
|
||||
option: 2,
|
||||
@@ -207,7 +209,7 @@ export function createCableOverlayPublisher({
|
||||
* real `MAP_STACKS` so the omission is caught loudly.
|
||||
*/
|
||||
const CABLE_GLOBE_STACK_IDS = Object.freeze(
|
||||
new Set(['bing-aerial', 'bing-labels', 'osm']),
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,7 +151,7 @@ test('cable ground lines classify against exactly the active surface on every st
|
||||
Cesium.ClassificationType.CESIUM_3D_TILE,
|
||||
);
|
||||
// Every globe stack renders imagery on the shown globe — terrain pass only.
|
||||
for (const stackId of ['bing-aerial', 'bing-labels', 'osm']) {
|
||||
for (const stackId of ['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']) {
|
||||
assert.equal(
|
||||
cableClassificationTypeForStack(stackId),
|
||||
Cesium.ClassificationType.TERRAIN,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// src/data/terrainHeights.js — batched, cached client terrain-height resolver.
|
||||
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
|
||||
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
|
||||
//
|
||||
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
|
||||
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/data/terrainHeights.test.mjs — batched, cached client terrain-height
|
||||
// resolver.
|
||||
// resolver (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
|
||||
//
|
||||
// Locks the module's public interface (Task 5/6 call it verbatim):
|
||||
// resolveEllipsoidalGround(coords: [{lat, lon, sourceOrthometricM?}])
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from './terrainHeights.js';
|
||||
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
|
||||
const AUSTIN_GEOID_N = -26.9; // Cross-checked against Re:Earth reference data.
|
||||
const AUSTIN_GEOID_N = -26.9; // docs/plans/2026-07-05-entity-height-datum-fix.md verified facts
|
||||
|
||||
/** Installs a fake fetch for the duration of `fn`, restoring the original after. */
|
||||
async function withFakeFetch(fakeFetch, fn) {
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
*
|
||||
* The fleet's 3D models stay behind the DISPLAY-rail "3D" toggle (`models3d`)
|
||||
* — hundreds of GLBs are a draw-call budget decision the operator owns. Since
|
||||
* 2026-08-22 that toggle DEFAULTS ON in `proximity` (product invariant), so the
|
||||
* 2026-08-22 that toggle DEFAULTS ON in `proximity` (owner directive), so the
|
||||
* fleet is armed on a fresh boot; proximity is itself the budget, admitting only
|
||||
* the nearest MODEL_MAX in view below the fleet ceiling. The contact you have
|
||||
* SELECTED is still a separate case: it is exactly one model, it is what the
|
||||
* camera is pointed at, and the operator's expectation is that zooming in on a
|
||||
* target resolves it into an aircraft. So the tracked contact's handoff is
|
||||
* DEFAULT behaviour, driven purely by camera distance and never consulting the
|
||||
* toggle at all (product invariant, 2026-08-19).
|
||||
* toggle at all (owner directive, 2026-08-19).
|
||||
*
|
||||
* Threshold history — the tracked contact used to inherit the fleet ceiling
|
||||
* `MODEL_ALT_CEIL_M = 800_000` m, and a first pass raised it to 1_000_000 m to
|
||||
* make the airframe arrive sooner. Field test 2026-08-20 rejected that: the
|
||||
* make the airframe arrive sooner. Owner playtest 2026-08-20 rejected that: the
|
||||
* model "pops to 3D far too early" — a 26 m airframe held at its minimum pixel
|
||||
* size from ~1 Mm out reads as a floating toy, not an aircraft. The ruling is a
|
||||
* MUCH closer swap: 2D is correct at ~600_000 m, and the handoff belongs at
|
||||
@@ -49,7 +49,7 @@
|
||||
export const FLEET_MODEL_ALT_CEIL_M = 800_000;
|
||||
|
||||
/** Camera altitude (m) at or below which the TRACKED contact takes its 3D model.
|
||||
* Field test ruling 2026-08-20: 2D still reads correctly at ~600_000 m, and
|
||||
* Owner playtest ruling 2026-08-20: 2D still reads correctly at ~600_000 m, and
|
||||
* the swap belongs at ~150_000 m. */
|
||||
export const TRACKED_MODEL_ENTER_ALT_M = 150_000;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// src/data/trackedModelRegime.test.mjs
|
||||
//
|
||||
// Zoom-driven 2D↔3D for the TRACKED contact (product invariant 2026-08-19).
|
||||
// Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19).
|
||||
//
|
||||
// Two things are pinned here, and both are behavioural rather than cosmetic:
|
||||
//
|
||||
// 1. The POLICY — thresholds and hysteresis — as pure math in
|
||||
// trackedModelRegime.js. The enter ceiling is the playtested swap
|
||||
// trackedModelRegime.js. The enter ceiling is the owner's playtested swap
|
||||
// distance, deliberately much NEARER than the fleet ceiling, and the exit
|
||||
// ceiling is deliberately higher than the enter ceiling. A regression that
|
||||
// collapsed the two thresholds back into one would silently reintroduce
|
||||
@@ -62,8 +62,8 @@ const WELL_INSIDE_M = TRACKED_MODEL_ENTER_ALT_M / 2;
|
||||
// 1. Policy: thresholds + hysteresis math
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the tracked 3D takeover sits at the playtested swap distance', () => {
|
||||
// Field test 2026-08-20: an earlier 1_000_000 m ceiling "pops to 3D far
|
||||
test('the tracked 3D takeover sits at the owner-playtested swap distance', () => {
|
||||
// Owner playtest 2026-08-20: an earlier 1_000_000 m ceiling "pops to 3D far
|
||||
// too early" — 2D still reads correctly at ~600_000 m and the swap belongs at
|
||||
// ~150_000 m. These are the numbers the operator judged by eye, so they are
|
||||
// pinned literally rather than derived from anything.
|
||||
@@ -75,7 +75,7 @@ test('the tracked 3D takeover sits at the playtested swap distance', () => {
|
||||
});
|
||||
|
||||
test('the tracked contact swaps NEARER than the fleet — a recorded inversion, not a bug', () => {
|
||||
// Consequence of the selected threshold, spelled out so it cannot be "tidied
|
||||
// Consequence of the owner's number, spelled out so it cannot be "tidied
|
||||
// away": with the DISPLAY-rail 3D toggle ON, camera altitudes between the
|
||||
// tracked ceiling and the fleet ceiling draw surrounding contacts as models
|
||||
// while the SELECTED one is still a billboard. The fleet pass skips the
|
||||
|
||||
@@ -124,7 +124,7 @@ test('civilian and military click handlers apply duration only at the deselect b
|
||||
);
|
||||
});
|
||||
|
||||
test('civilian and military tracked model caps both expose the selected 200 px feel', () => {
|
||||
test('civilian and military tracked model caps both expose the owner-selected 200 px feel', () => {
|
||||
assert.equal(CIVIL_TRACKED_MODEL_MAX_PX, 200);
|
||||
assert.equal(MILITARY_TRACKED_MODEL_MAX_PX, 200);
|
||||
});
|
||||
+7
-7
@@ -223,7 +223,7 @@ let _uncoveredMode = 'sim';
|
||||
* + stop-and-go creep; 'heatline' = congestion corridor polylines; 'both';
|
||||
* 'none' = shipped main behavior. Live mode only — the keyless simulation
|
||||
* never has `road.flow`, so every jamViz path is unreachable there.
|
||||
* Default 'density' — A/B verdict 2026-07-23 (heatline stays available
|
||||
* Default 'density' — owner A/B verdict 2026-07-23 (heatline stays available
|
||||
* via setParams).
|
||||
* @type {'none'|'density'|'heatline'|'both'}
|
||||
*/
|
||||
@@ -270,7 +270,7 @@ let _activeBucketColors = { ...FLOW_BUCKET_COLORS };
|
||||
/**
|
||||
* @const {number} Minimum base pixel size for COLORED dots while a styled
|
||||
* preset is active — residential-road dots spawn at 4 px and vanish into
|
||||
* post-FX pixelation; presence is the dots' whole job there (follow-up round
|
||||
* post-FX pixelation; presence is the dots' whole job there (owner round
|
||||
* 2). Sim dots and the normal profile keep SIZE_BY_TYPE untouched.
|
||||
*/
|
||||
const STYLED_MIN_BASE_PX = 5;
|
||||
@@ -1253,7 +1253,7 @@ export function trafficFeedPresentation({
|
||||
const mode = liveMode ? 'live' : 'sim';
|
||||
if (liveMode && flowError) {
|
||||
// One string for both fields. The manager's meta line renders `error` and
|
||||
// drops `loadingLabel` in its error branch, so the SIMULATED copy
|
||||
// drops `loadingLabel` in its error branch, so the owner's SIMULATED copy
|
||||
// has to BE the error text or the steady state reverts to a bare
|
||||
// "TomTom daily budget reached" that never says what is on screen.
|
||||
const degraded = `SIMULATED — ${flowError}`;
|
||||
@@ -1269,7 +1269,7 @@ export function trafficFeedPresentation({
|
||||
};
|
||||
}
|
||||
// Keyless simulation — one terse line that names the mode and the remedy
|
||||
// (the copy shape). The chip's own progress text carries "working";
|
||||
// (owner's copy shape). The chip's own progress text carries "working";
|
||||
// this line must never imply a live feed.
|
||||
return {
|
||||
mode,
|
||||
@@ -2351,13 +2351,13 @@ const trafficLayer = {
|
||||
if (params.uncoveredRoads === 'sim' || params.uncoveredRoads === 'hide') {
|
||||
_uncoveredMode = params.uncoveredRoads;
|
||||
}
|
||||
// Jam-viz prototype toggle (A/B): 'none' = shipped main behavior;
|
||||
// Jam-viz prototype toggle (owner A/B): 'none' = shipped main behavior;
|
||||
// live mode only, applies on the next camera-driven load like the
|
||||
// uncoveredRoads param above.
|
||||
if (['none', 'density', 'heatline', 'both'].includes(params.jamViz)) {
|
||||
_jamViz = params.jamViz;
|
||||
}
|
||||
// Preset-aware dot styling kill switch (A/B): 'off' forces the
|
||||
// Preset-aware dot styling kill switch (owner A/B): 'off' forces the
|
||||
// shipped palette under every post-FX preset. Applies immediately via
|
||||
// in-place restyle — no refetch — so A/B legs share identical dots.
|
||||
if (params.presetDots === 'on' || params.presetDots === 'off') {
|
||||
@@ -2415,7 +2415,7 @@ const trafficLayer = {
|
||||
};
|
||||
// Live mode: the detection bracket carries the congestion signal —
|
||||
// its canvas sits ABOVE the post-FX chain, so tier colors survive
|
||||
// every preset (follow-up round 2: "bounding boxes do the heavy
|
||||
// every preset (owner round 2: "bounding boxes do the heavy
|
||||
// lifting"). Keyless mode sets no tier: contacts keep the stock
|
||||
// 'vehicle' bracket and the keyless experience stays untouched.
|
||||
if (_liveMode) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 0.72 / jam 0.49 — under NVG a jam renders DIMMER than free flow. CRT
|
||||
* keeps hue but its 5-px pixelation + dithering shred 4–6 px dots.
|
||||
*
|
||||
* Encoding per profile (validated behavior 2026-07-23 round 2: "just bright
|
||||
* Encoding per profile (owner verdict 2026-07-23 round 2: "just bright
|
||||
* dots" — a luminance RAMP failed in the field because dim free-flow dots
|
||||
* read as dark holes on NVG-bright roads; classification is the detection
|
||||
* brackets' job via `trafficBucketTier`, presence is the dots' job):
|
||||
|
||||
@@ -39,7 +39,7 @@ test('sim/uncovered dots (null bucket) are untouched under EVERY style — keyle
|
||||
}
|
||||
});
|
||||
|
||||
test('mono: EVERY colored dot is a bright white core (follow-up round 2: "just bright dots")', () => {
|
||||
test('mono: EVERY colored dot is a bright white core (owner round 2: "just bright dots")', () => {
|
||||
for (const s of MONO_STYLES) {
|
||||
for (const b of BUCKETS) {
|
||||
const rgba = presetDotRgba(s, b);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two product invariants from the field:
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
@@ -36,7 +36,7 @@ let _trailSeq = 0;
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* still visible, but readable as occluded. */
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
@@ -72,10 +72,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The locked rule (round 6): a segment below the photoreal
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8: NONE draws straight 3D chords between waypoints — over a
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
|
||||
@@ -56,3 +56,26 @@ test('an inherited export never masks the value written in the file', async () =
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('dev-fresh gives explicit or dotenv Google configuration precedence over Keychain', async () => {
|
||||
const source = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8');
|
||||
const precedence = source.indexOf('if [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]');
|
||||
const keychainFallback = source.indexOf('elif [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]');
|
||||
assert.ok(precedence >= 0 && keychainFallback > precedence);
|
||||
});
|
||||
|
||||
test('dev-fresh passes names-only boot provenance before resolving file fallbacks', async () => {
|
||||
const source = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8');
|
||||
const capture = source.indexOf('KEY_SETUP_EXTERNAL_KEYS=()');
|
||||
const dotenvResolution = source.indexOf('GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"');
|
||||
assert.ok(capture >= 0 && capture < dotenvResolution, 'parent-shell provenance must be captured first');
|
||||
for (const name of [
|
||||
'GOOGLE_MAPS_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', 'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN',
|
||||
]) {
|
||||
assert.match(source, new RegExp(`KEY_SETUP_EXTERNAL_KEYS\\+=\\(${name}\\)`));
|
||||
}
|
||||
assert.match(source, /put_env GEV_LAUNCHER "dev-fresh"/);
|
||||
assert.match(source, /put_env GEV_KEY_SETUP_EXTERNAL_KEYS "\$\{KEY_SETUP_EXTERNAL_KEYS_CSV\}"/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
// would spend optional API quotas, surprise returning operators, and fight share
|
||||
// links. A new visitor instead gets one compact, explicit choice after startup.
|
||||
//
|
||||
// SHOW POLICY (product decision, 2026-08-23). The launcher is NOT one-shot. A new
|
||||
// SHOW POLICY (owner ruling, 2026-08-23). The launcher is NOT one-shot. A new
|
||||
// operator needs the map explained more than once, so it returns every fresh
|
||||
// browser session until they say otherwise:
|
||||
//
|
||||
@@ -25,7 +25,7 @@ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
|
||||
|
||||
/**
|
||||
* Configurable name for the fires/quakes mission. Flip this ONE constant to
|
||||
* Owner-selectable name for the fires/quakes mission. Flip this ONE constant to
|
||||
* re-label the tile; the alternates are pre-written so the choice is a taste
|
||||
* call at review time, not an edit.
|
||||
* @type {'ENVIRONMENTAL'|'EARTH_WATCH'|'ACTIVE_EVENTS'}
|
||||
@@ -49,7 +49,7 @@ export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
|
||||
/*
|
||||
* MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Product decision: picking a mission carries the same weight as clicking the
|
||||
* Owner ruling: picking a mission carries the same weight as clicking the
|
||||
* toggles it represents — durable where those clicks are durable — but it must
|
||||
* never write a preference the visitor did not effectively choose by picking it.
|
||||
* Layer enablement IS durable in this app (`gev:layer-state:v2`, written by
|
||||
@@ -100,7 +100,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
environmental: Object.freeze({
|
||||
kind: 'globe',
|
||||
// Live USGS earthquakes AND NASA FIRMS active fires. The launcher optimizes
|
||||
// for the FULLY CONFIGURED experience (product decision, 2026-08-23): the tile
|
||||
// for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile
|
||||
// promises both, so it turns on both, and the subcopy in index.html says so.
|
||||
//
|
||||
// Keyless, FIRMS is honest where it counts — its own layer row reads
|
||||
@@ -333,7 +333,7 @@ export function initFirstRunExperience({
|
||||
return null;
|
||||
}
|
||||
|
||||
// The tile name is configurable from one constant, so paint it from the
|
||||
// The tile name is owner-switchable from one constant, so paint it from the
|
||||
// module rather than trusting the markup to have been edited to match.
|
||||
const environmentalTitle = root.querySelector('[data-first-run-environmental-title]');
|
||||
if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title;
|
||||
|
||||
@@ -396,7 +396,7 @@ function missionSpy({ contextOk = true, layerResult = () => true, globe = async
|
||||
}
|
||||
|
||||
test('the menu is the four owner-ordered missions', () => {
|
||||
// INFRASTRUCTURE was removed after the field tested it: enabling all
|
||||
// INFRASTRUCTURE was removed after the owner playtested it: enabling all
|
||||
// three bundled layers at once put ~5,700 entities on a full-earth view and
|
||||
// tanked the frame rate. The layers stay reachable by hand and by voice; what
|
||||
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||
@@ -429,7 +429,7 @@ test('Environmental enables BOTH its feeds and pulls out to the globe', async ()
|
||||
});
|
||||
|
||||
test('the tile is the FULLY CONFIGURED experience: quakes and fires together', () => {
|
||||
// Product decision, 2026-08-23: the launcher optimizes for the configured app, so
|
||||
// Owner ruling, 2026-08-23: the launcher optimizes for the configured app, so
|
||||
// ENVIRONMENTAL means live USGS earthquakes AND NASA FIRMS active fires.
|
||||
const environmental = FIRST_RUN_MISSIONS.environmental;
|
||||
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
|
||||
@@ -568,7 +568,7 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
assert.ok(
|
||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
||||
'the final first-run line must ship exactly as written',
|
||||
'the owner-authored first-run line must ship exactly as written',
|
||||
);
|
||||
|
||||
// Menu order is the owner's, read straight off the markup.
|
||||
@@ -605,7 +605,9 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
assert.match(css, /#first-run-launcher\[hidden\] \{\s*display: none;\s*\}/);
|
||||
// Only the mission list may scroll: the heading, checkbox and status line
|
||||
// have to stay on screen at every height.
|
||||
assert.match(css, /\.first-run-choices \{[\s\S]*?min-height: 0;[\s\S]*?overflow-y: auto/);
|
||||
const choicesBlock = css.match(/\.first-run-choices \{([^}]*)\}/)?.[1] || '';
|
||||
assert.match(choicesBlock, /min-height: 0;/);
|
||||
assert.match(choicesBlock, /overflow-y: auto;/);
|
||||
});
|
||||
|
||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||
@@ -653,10 +655,15 @@ test('the voice TOOL SCHEMA is byte-identical to main — the mission mapping is
|
||||
const end = src.indexOf('\n];\n', start);
|
||||
const block = src.slice(start, end + 4);
|
||||
|
||||
assert.equal(block.length, 31104, 'tool schema byte length drifted from the frozen baseline');
|
||||
// Re-pinned 2026-08-28: the Provider Settings / Esri release DELIBERATELY
|
||||
// extends set_map_stack's enum with 'esri-imagery' (a real new basemap —
|
||||
// exactly the kind of schema change this pin exists to make loud). The
|
||||
// guarded claim is unchanged: first-run missions ride existing tools, and
|
||||
// any NEW drift from this recorded schema still fails here.
|
||||
assert.equal(block.length, 31189, 'tool schema byte length drifted from the pinned release schema');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'3ace199727934e851902e4899c423d549d34d3f53469dcb56f07fc070d3f9d66',
|
||||
'73aaabdb169a5478893d28688f327a21edd32ed3ec16fc6287bd944ed77beecf',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { googlePlacesContextProxy, keylessGooglePlacesResponse } from '../vite.config.js';
|
||||
|
||||
const KEYLESS_PAYLOAD = { configured: false, error: null, places: [] };
|
||||
|
||||
function installGooglePlacesRoutes() {
|
||||
const routes = new Map();
|
||||
googlePlacesContextProxy().configureServer({
|
||||
middlewares: {
|
||||
use(path, handler) {
|
||||
routes.set(path, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
method,
|
||||
url,
|
||||
headers: {},
|
||||
socket: { remoteAddress },
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({
|
||||
statusCode: this.statusCode,
|
||||
headers: Object.fromEntries(headers),
|
||||
body: body ? JSON.parse(String(body)) : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('builds the keyless capability response only for a blank key', () => {
|
||||
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
|
||||
statusCode: 200,
|
||||
payload: KEYLESS_PAYLOAD,
|
||||
});
|
||||
assert.deepEqual(keylessGooglePlacesResponse(' '), {
|
||||
statusCode: 200,
|
||||
payload: KEYLESS_PAYLOAD,
|
||||
});
|
||||
assert.equal(keylessGooglePlacesResponse('configured-key'), null);
|
||||
});
|
||||
|
||||
test('keyless Places routes stay successful after the Google quota is exhausted', async () => {
|
||||
const previousKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
const previousLimit = process.env.GEV_RATELIMIT_GOOGLE_PER_MIN;
|
||||
process.env.GOOGLE_MAPS_API_KEY = '';
|
||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN = '1';
|
||||
try {
|
||||
const routes = installGooglePlacesRoutes();
|
||||
const nearby = routes.get('/api/google/nearby-places');
|
||||
const textSearch = routes.get('/api/google/text-search');
|
||||
assert.equal(typeof nearby, 'function');
|
||||
assert.equal(typeof textSearch, 'function');
|
||||
|
||||
// The limiter allows one request per minute; a keyless capability response
|
||||
// must never consume that quota, so every request on both routes — well
|
||||
// past the limit — stays the same deliberate 200.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const nearbyResponse = await invokeRoute(nearby, { url: '/?lat=30.27&lon=-97.74' });
|
||||
assert.equal(nearbyResponse.statusCode, 200);
|
||||
assert.equal(nearbyResponse.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(nearbyResponse.body, KEYLESS_PAYLOAD);
|
||||
|
||||
const textResponse = await invokeRoute(textSearch, { url: '/?q=capitol&lat=30.27&lon=-97.74' });
|
||||
assert.equal(textResponse.statusCode, 200);
|
||||
assert.equal(textResponse.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(textResponse.body, KEYLESS_PAYLOAD);
|
||||
}
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
else process.env.GOOGLE_MAPS_API_KEY = previousKey;
|
||||
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_GOOGLE_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_GOOGLE_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
+6
-1
@@ -19,6 +19,7 @@ import { CITY_POIS } from './locations.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -665,10 +666,14 @@ export class IntelHUD {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (revision !== this._summaryRevision) return;
|
||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
return;
|
||||
}
|
||||
if (!response.ok || !data?.summary) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (revision !== this._summaryRevision) return;
|
||||
this._setSummaryText(data.summary, animate);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
|
||||
@@ -49,7 +49,7 @@ const has = (pattern) => pattern.test(source);
|
||||
|
||||
/** SFO runway 28R touchdown area — the field report's coordinates. */
|
||||
const SFO = { latDeg: 37.616, lonDeg: -122.368 };
|
||||
/** The ellipsoidal camera height the screenshot reported. */
|
||||
/** The ellipsoidal camera height the owner's screenshot reported. */
|
||||
const SFO_ELLIPSOIDAL_M = -15;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||
|
||||
/**
|
||||
* Describe the optional HUD summary capability without turning a deliberately
|
||||
* keyless boot into an HTTP failure.
|
||||
*
|
||||
* @param {unknown} apiKey - Candidate server-side OpenAI credential.
|
||||
* @returns {{ statusCode: 200, payload: { configured: false, code: string, error: null, summary: null } }|null}
|
||||
* A graceful unconfigured response, or null when the provider is configured.
|
||||
*/
|
||||
export function keylessHudSummaryResponse(apiKey) {
|
||||
if (String(apiKey ?? '').trim()) return null;
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: {
|
||||
configured: false,
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
error: null,
|
||||
summary: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Return true only for the deliberate, successful no-key capability response. */
|
||||
export function isHudSummaryUnconfigured(status, data) {
|
||||
const keys = data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return keys.length === 4
|
||||
&& status === 200
|
||||
&& data?.configured === false
|
||||
&& data?.code === HUD_SUMMARY_UNCONFIGURED_CODE
|
||||
&& data?.error === null
|
||||
&& data?.summary === null;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
} from './hudSummaryResponse.js';
|
||||
import { openAiRealtimeProxy } from '../vite.config.js';
|
||||
|
||||
const UNCONFIGURED_PAYLOAD = {
|
||||
configured: false,
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
error: null,
|
||||
summary: null,
|
||||
};
|
||||
|
||||
function installOpenAiRoutes() {
|
||||
const routes = new Map();
|
||||
openAiRealtimeProxy().configureServer({
|
||||
middlewares: {
|
||||
use(path, handler) {
|
||||
routes.set(path, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
method,
|
||||
url,
|
||||
headers: {},
|
||||
socket: { remoteAddress },
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) {
|
||||
headers.set(String(name).toLowerCase(), String(value));
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({
|
||||
statusCode: this.statusCode,
|
||||
headers: Object.fromEntries(headers),
|
||||
body: body ? JSON.parse(String(body)) : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('builds an HTTP-success capability response only for a blank key', () => {
|
||||
assert.deepEqual(keylessHudSummaryResponse(undefined), {
|
||||
statusCode: 200,
|
||||
payload: UNCONFIGURED_PAYLOAD,
|
||||
});
|
||||
assert.deepEqual(keylessHudSummaryResponse(' '), {
|
||||
statusCode: 200,
|
||||
payload: UNCONFIGURED_PAYLOAD,
|
||||
});
|
||||
assert.equal(keylessHudSummaryResponse('configured-key'), null);
|
||||
});
|
||||
|
||||
test('recognizes only the exact deliberate no-key fallback response', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
error: 'provider failed',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
summary: 'Unexpected provider output',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
configured: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
unexpected: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('does not hide real provider and HTTP failures', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(502, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
|
||||
});
|
||||
|
||||
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
process.env.OPENAI_API_KEY = '';
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '1';
|
||||
try {
|
||||
const routes = installOpenAiRoutes();
|
||||
const token = routes.get('/api/realtime/token');
|
||||
const hud = routes.get('/api/openai/hud-summary');
|
||||
assert.equal(typeof token, 'function');
|
||||
assert.equal(typeof hud, 'function');
|
||||
|
||||
const firstToken = await invokeRoute(token);
|
||||
const secondToken = await invokeRoute(token);
|
||||
assert.equal(firstToken.statusCode, 503);
|
||||
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
|
||||
assert.equal(secondToken.statusCode, 429);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await invokeRoute(hud, { method: 'POST' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
||||
}
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* The POWER UP surface — paste a key, get a power.
|
||||
*
|
||||
* A small chip sits bottom-right whenever the app is running under the dev
|
||||
* server with keys still missing. It opens a dialog rendered ENTIRELY from
|
||||
* GET /api/setup/status (the registry lives in src/keySetupCore.mjs and this
|
||||
* module never duplicates it): one row per key, what it unlocks, where to get
|
||||
* it, and a paste field. SAVE posts to /api/setup/keys, which writes the
|
||||
* repo-root .env and restarts the dev server — Vite's client then reloads the
|
||||
* page itself, and the pasted key is simply *on*. No hand-edited env files.
|
||||
*
|
||||
* The surface self-destructs where it cannot work: a prod build (no endpoint)
|
||||
* or a LAN visitor (loopback-only endpoint) fails the status fetch, and both
|
||||
* the chip and the dialog are removed outright.
|
||||
*/
|
||||
|
||||
/** Chip label — pure, exported for tests. */
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0 ? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING` : 'POWERED UP';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a POST body from field descriptors — pure, exported for tests.
|
||||
* @param {Array<{envVar: string, value: string}>} fields
|
||||
* @returns {Record<string, string>} non-empty trimmed values only
|
||||
*/
|
||||
export function collectKeyUpdates(fields) {
|
||||
const updates = {};
|
||||
for (const field of fields || []) {
|
||||
const value = String(field?.value ?? '').trim();
|
||||
if (value && field?.envVar) updates[field.envVar] = value;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the FIRST Google key lands, the restart's reload should boot the
|
||||
* photoreal default — not faithfully restore the auto-selected keyless OSM
|
||||
* basemap from the URL's live share hash. Strips only `map=osm`: a stack under
|
||||
* any other name was chosen or shared on purpose and survives, and so does
|
||||
* everything else in the hash (camera, style, layers). Pure, exported for tests.
|
||||
* @param {string} hash Location hash without the leading '#'.
|
||||
* @returns {string|null} The rewritten hash, or null when there is nothing to strip.
|
||||
*/
|
||||
export function stripKeylessBasemapFromHash(hash) {
|
||||
if (!hash) return null;
|
||||
try {
|
||||
const params = new URLSearchParams(hash);
|
||||
if (!['osm', 'esri-imagery'].includes(params.get('map'))) return null;
|
||||
params.delete('map');
|
||||
return params.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
|
||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
||||
function buildRow(documentRef, key) {
|
||||
const row = documentRef.createElement('section');
|
||||
row.className = 'key-setup-row';
|
||||
row.dataset.keyId = key.id;
|
||||
row.dataset.set = String(Boolean(key.set));
|
||||
if (key.managed) row.dataset.managed = key.managed;
|
||||
const external = key.managed === 'external';
|
||||
|
||||
const head = documentRef.createElement('div');
|
||||
head.className = 'key-setup-row-head';
|
||||
const led = documentRef.createElement('span');
|
||||
led.className = 'key-setup-led';
|
||||
led.setAttribute('aria-hidden', 'true');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = key.title;
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title = key.tier === 'metered' ? 'Metered — a billing-enabled account' : 'Free key — register, paste, done';
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title = 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
// Externally supplied credentials (shell env, Keychain, a launcher) are
|
||||
// facts this panel reports, never values it rewrites or deletes.
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title = 'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
get.className = 'key-setup-get';
|
||||
get.href = key.getUrl;
|
||||
get.target = '_blank';
|
||||
get.rel = 'noopener noreferrer';
|
||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
||||
head.append(get);
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
unlocks.className = 'key-setup-unlocks';
|
||||
unlocks.textContent = key.unlocks;
|
||||
|
||||
row.append(head, unlocks);
|
||||
if (!external) {
|
||||
const fields = documentRef.createElement('div');
|
||||
fields.className = 'key-setup-fields';
|
||||
for (const envVar of key.envVars) {
|
||||
const input = documentRef.createElement('input');
|
||||
// Passwords-style so a pasted key never shows on a shared or recorded
|
||||
// screen — this app gets screen-recorded a lot.
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.dataset.envVar = envVar;
|
||||
input.setAttribute('aria-label', envVar);
|
||||
input.placeholder = key.set
|
||||
? `${envVar} saved — paste to replace`
|
||||
: `paste ${envVar}`;
|
||||
fields.append(input);
|
||||
}
|
||||
if (key.managed === 'file') {
|
||||
const remove = documentRef.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'key-setup-remove';
|
||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
||||
remove.textContent = 'REMOVE';
|
||||
remove.title = `Remove ${key.title} from this app's saved keys`;
|
||||
fields.append(remove);
|
||||
}
|
||||
row.append(fields);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||
*/
|
||||
export async function initKeySetup({ documentRef = globalThis.document, fetchImpl } = {}) {
|
||||
const chip = documentRef?.getElementById?.('key-setup-chip');
|
||||
const root = documentRef?.getElementById?.('key-setup');
|
||||
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
||||
root.dataset.initialized = 'true';
|
||||
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
const response = await doFetch('/api/setup/status', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
status = await response.json();
|
||||
} catch {
|
||||
// Prod build or non-loopback visitor: the surface cannot function, so it
|
||||
// does not exist. (The README covers .env for headless/self-host setups.)
|
||||
chip.remove();
|
||||
root.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
||||
const defaultStatusText = statusLine?.textContent || '';
|
||||
let busy = false;
|
||||
let open = false;
|
||||
let previouslyFocused = null;
|
||||
|
||||
const render = (nextStatus) => {
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
chip.hidden = status.setCount >= status.total;
|
||||
if (!rowsHost) return;
|
||||
rowsHost.textContent = '';
|
||||
for (const key of status.keys || []) rowsHost.append(buildRow(documentRef, key));
|
||||
};
|
||||
|
||||
const visible = () => root.isConnected
|
||||
&& root.classList.contains('visible')
|
||||
&& root.getClientRects().length > 0;
|
||||
|
||||
const focusables = () => [
|
||||
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
|
||||
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (!open || !visible()) return;
|
||||
// Cooperative ESC contract (see firstRunExperience.js): whoever handles a
|
||||
// key first marks it, and everyone else honours the mark.
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const order = focusables();
|
||||
if (!order.length) return;
|
||||
const first = order[0];
|
||||
const last = order[order.length - 1];
|
||||
const active = documentRef.activeElement;
|
||||
if (!root.contains(active)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
if (open) return;
|
||||
open = true;
|
||||
previouslyFocused = documentRef.activeElement;
|
||||
root.hidden = false;
|
||||
documentRef.addEventListener('keydown', onKeyDown, true);
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
if (!open) return;
|
||||
root.classList.add('visible');
|
||||
root.querySelector('input')?.focus?.({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
||||
root.classList.remove('visible');
|
||||
const hide = () => { if (!open) root.hidden = true; };
|
||||
root.addEventListener('transitionend', hide, { once: true });
|
||||
globalThis.setTimeout?.(hide, 400);
|
||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
||||
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
|
||||
previouslyFocused.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
||||
|
||||
const storeLabel = () => (status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env');
|
||||
|
||||
const submitUpdates = async (updates, doneVerb) => {
|
||||
if (busy) return;
|
||||
const googleWasUnset = !status?.keys?.find((key) => key.id === 'google-maps')?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say('Saving…');
|
||||
try {
|
||||
const response = await doFetch('/api/setup/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload.ok) {
|
||||
say(payload.error || `Save failed (${response.status}).`);
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]')) input.value = '';
|
||||
render(payload.status);
|
||||
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
||||
const strip = () => {
|
||||
try {
|
||||
const next = stripKeylessBasemapFromHash(globalThis.location?.hash?.slice(1) || '');
|
||||
if (next !== null) globalThis.history?.replaceState?.(null, '', `#${next}`);
|
||||
} catch {
|
||||
// Continuity is a nicety, never a blocker.
|
||||
}
|
||||
};
|
||||
strip();
|
||||
// The live share writer may re-serialize the still-OSM stack before
|
||||
// the restart's reload lands, so strip again at the door.
|
||||
globalThis.addEventListener?.('pagehide', strip, { once: true });
|
||||
}
|
||||
say(`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`);
|
||||
} catch (error) {
|
||||
say(`Save failed: ${error?.message || error}`);
|
||||
} finally {
|
||||
busy = false;
|
||||
applyButton?.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const onApply = async () => {
|
||||
if (busy) return;
|
||||
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
||||
const updates = collectKeyUpdates(
|
||||
inputs.map((input) => ({ envVar: input.dataset.envVar, value: input.value })),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say('Paste at least one key first.');
|
||||
return;
|
||||
}
|
||||
await submitUpdates(updates, 'Saved to');
|
||||
};
|
||||
|
||||
chip.addEventListener('click', openDialog);
|
||||
closeButton?.addEventListener('click', close);
|
||||
applyButton?.addEventListener('click', onApply);
|
||||
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
||||
rowsHost?.addEventListener('click', (event) => {
|
||||
const button = event.target?.closest?.('[data-key-setup-remove]');
|
||||
if (!button || busy) return;
|
||||
let envVars = [];
|
||||
try {
|
||||
envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(envVars) || !envVars.length) return;
|
||||
// Removal is destructive and — behind a framing defense that should already
|
||||
// stop it — a clickjack target. A confirm turns a single aligned click into
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok = typeof globalThis.confirm !== 'function'
|
||||
|| globalThis.confirm('Remove this key from your saved configuration?');
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
'Removed from',
|
||||
);
|
||||
});
|
||||
|
||||
render(status);
|
||||
|
||||
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
||||
// dialog even though the chip has retired.
|
||||
try {
|
||||
if (new URLSearchParams(globalThis.location?.search || '').get('setup') === '1') openDialog();
|
||||
} catch {
|
||||
// An unparsable location never blocks init.
|
||||
}
|
||||
|
||||
return { open: openDialog, close, render };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
collectKeyUpdates,
|
||||
keySetupChipLabel,
|
||||
stripKeylessBasemapFromHash,
|
||||
} from './keySetup.js';
|
||||
|
||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
|
||||
assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
|
||||
});
|
||||
|
||||
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
||||
const updates = collectKeyUpdates([
|
||||
{ envVar: 'OPENAI_API_KEY', value: ' sk-abc ' },
|
||||
{ envVar: 'FIRMS_MAP_KEY', value: '' },
|
||||
{ envVar: 'TOMTOM_API_KEY', value: ' ' },
|
||||
{ envVar: '', value: 'orphan' },
|
||||
null,
|
||||
]);
|
||||
assert.deepEqual(updates, { OPENAI_API_KEY: 'sk-abc' });
|
||||
assert.deepEqual(collectKeyUpdates([]), {});
|
||||
assert.deepEqual(collectKeyUpdates(null), {});
|
||||
});
|
||||
|
||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||
assert.ok(stripped !== null);
|
||||
const params = new URLSearchParams(stripped);
|
||||
assert.equal(params.get('map'), null, 'osm basemap removed');
|
||||
assert.equal(params.get('lat'), '30.2', 'camera survives');
|
||||
assert.equal(params.get('style'), 'normal', 'style survives');
|
||||
// A stack under any other name was chosen or shared on purpose.
|
||||
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
|
||||
assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
|
||||
assert.equal(stripKeylessBasemapFromHash(''), null);
|
||||
assert.equal(stripKeylessBasemapFromHash(undefined), null);
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Key setup ("POWER UP") — the pure core.
|
||||
*
|
||||
* One registry, three pure functions, zero dependencies. The dev server's
|
||||
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
|
||||
* are both thin shells over this module, so what a key is called, what it
|
||||
* unlocks, and how a .env line is written each live in exactly one place.
|
||||
*
|
||||
* Nothing here touches the filesystem, the network, or process.env — callers
|
||||
* pass environments in and write text out, which is also what makes every
|
||||
* behavior below unit-testable.
|
||||
*/
|
||||
|
||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||
export const KEY_SETUP_VALUE_LIMIT = 512;
|
||||
|
||||
/** Most env vars accepted in one save. The registry defines nine. */
|
||||
export const KEY_SETUP_UPDATE_LIMIT = 16;
|
||||
|
||||
/** Header line written above keys the panel appends to a .env file. */
|
||||
export const KEY_SETUP_APPEND_HEADER = '# Keys added by the in-app POWER UP panel';
|
||||
|
||||
/**
|
||||
* Every key the panel offers, in the order it offers them — most magic per
|
||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
||||
* `clientExposed` marks the two keys that are injected into the browser
|
||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||
*/
|
||||
export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet + place search',
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
clientExposed: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
unlocks: 'Voice control — talk to the planet',
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
tier: 'metered',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM',
|
||||
unlocks: 'Live ships, worldwide',
|
||||
getUrl: 'https://aisstream.io',
|
||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
unlocks: 'Live active-fire detections',
|
||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
unlocks: 'Real live traffic (keyless runs a simulation)',
|
||||
getUrl: 'https://developer.tomtom.com',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'cesium-ion',
|
||||
title: 'CESIUM ION',
|
||||
unlocks: 'Bing imagery map stacks + world terrain',
|
||||
getUrl: 'https://ion.cesium.com/tokens',
|
||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||
tier: 'free',
|
||||
clientExposed: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'opensky',
|
||||
title: 'OPENSKY',
|
||||
unlocks: 'More flight-polling credits (anonymous works without)',
|
||||
getUrl: 'https://opensky-network.org',
|
||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY',
|
||||
unlocks: 'Higher space-missions request allowance',
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Hostnames a Provider Settings request may arrive under or originate from. */
|
||||
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
||||
/** Socket addresses that count as this machine. */
|
||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
/** Parse an exact local request authority from a Host header. */
|
||||
function localAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '').trim().toLowerCase();
|
||||
const scheme = String(protocol || '').toLowerCase();
|
||||
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw)) return null;
|
||||
try {
|
||||
const parsed = new URL(`${scheme}//${raw}`);
|
||||
return LOCAL_HOSTNAMES.has(parsed.hostname.toLowerCase()) ? parsed.origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True only for a subprocess that exited normally and successfully. */
|
||||
export function commandCompletedSuccessfully(result) {
|
||||
return !!result && !result.error && !result.signal && result.status === 0;
|
||||
}
|
||||
|
||||
/** Parse one RFC-4180-shaped CSV record, sufficient for `whoami /fo csv`. */
|
||||
function parseCsvRecord(text) {
|
||||
const source = String(text || '').replace(/^\uFEFF/, '').trim();
|
||||
if (!source || /[\r\n]/.test(source)) return null;
|
||||
const fields = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quoted) {
|
||||
if (char === '"' && source[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = false;
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
} else if (char === '"' && field === '') {
|
||||
quoted = true;
|
||||
} else if (char === ',') {
|
||||
fields.push(field);
|
||||
field = '';
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
if (quoted) return null;
|
||||
fields.push(field);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current token's user SID from `whoami /user /fo csv /nh`.
|
||||
* The SID must be the second CSV field and a user-shaped local/domain or Entra
|
||||
* SID; matching an SID-looking account name or a broad group SID is forbidden.
|
||||
*/
|
||||
export function parseWindowsUserSid(stdout) {
|
||||
const fields = parseCsvRecord(stdout);
|
||||
if (!fields || fields.length !== 2) return null;
|
||||
const sid = fields[1].trim();
|
||||
return /^(?:S-1-5-21-(?:\d+-){3}\d+|S-1-12-1-(?:\d+-){3}\d+)$/i.test(sid)
|
||||
? sid
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admission gate for the Provider Settings endpoints — pure, exported so
|
||||
* every refusal below is pinned by a unit assertion rather than a review note.
|
||||
*
|
||||
* Why each check exists:
|
||||
* - sharing signals: any tunnel/LAN sharing mode disables the surface
|
||||
* outright — a credential-writing endpoint has no business existing on a
|
||||
* shared instance, and tunnel traffic reaches the server FROM loopback, so
|
||||
* the socket check below cannot carry that boundary alone;
|
||||
* - loopback socket: refuses LAN peers when the server is bound wide;
|
||||
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
|
||||
* Host even when the socket says loopback;
|
||||
* - exact same Origin on POST: a hostile web page can make a browser POST to
|
||||
* localhost, and a non-browser caller must not bypass that boundary merely
|
||||
* by omitting the header;
|
||||
* - JSON Content-Type on POST: forces cross-origin browsers into a CORS
|
||||
* preflight this server never answers, closing the simple-request CSRF
|
||||
* write primitive.
|
||||
*
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string}}
|
||||
*/
|
||||
export function admitKeySetupRequest({
|
||||
method,
|
||||
remoteAddress,
|
||||
hostHeader,
|
||||
protocol = 'http:',
|
||||
origin,
|
||||
contentType,
|
||||
proxyHeaders = {},
|
||||
env = {},
|
||||
} = {}) {
|
||||
// A request carrying reverse-proxy / CDN forwarding headers did not originate
|
||||
// on this machine, whatever its socket says. Refuse them outright as defense
|
||||
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
|
||||
// only appear when someone has deliberately fronted the dev server.
|
||||
const PROXY_SIGNALS = ['forwarded', 'via', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'cf-ray'];
|
||||
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings does not answer proxied requests' };
|
||||
}
|
||||
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
||||
// also disables this surface — so the gate's set is complete, not a subset the
|
||||
// two files could drift apart on. One DELIBERATE divergence: preflight is a
|
||||
// boot check that treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed
|
||||
// before Start), but here an empty/unset value is the NORMAL git-clone and
|
||||
// Pinokio state — treating it as sharing would disable Provider Settings for
|
||||
// every ordinary launch. So a bare/sentinel value is not sharing; only a real
|
||||
// tunnel var is. This is defense in depth regardless: the loopback+Host checks
|
||||
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
|
||||
// refuses to boot at all when sharing is genuinely on.
|
||||
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
|
||||
const sharingEnabled = ['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL']
|
||||
.some((name) => /^(1|true)$/i.test(String(env[name] || '').trim()))
|
||||
|| (shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
|
||||
if (sharingEnabled) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings is disabled while sharing is enabled' };
|
||||
}
|
||||
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings answers only the machine running the server' };
|
||||
}
|
||||
const authority = localAuthority(hostHeader, protocol);
|
||||
if (!authority) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings answers only local hostnames' };
|
||||
}
|
||||
if (method === 'POST' && (origin === undefined || origin === null || origin === '')) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings requires an exact local Origin' };
|
||||
}
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
let parsedOrigin;
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
}
|
||||
const exactOrigin = parsedOrigin.username === ''
|
||||
&& parsedOrigin.password === ''
|
||||
&& parsedOrigin.pathname === '/'
|
||||
&& parsedOrigin.search === ''
|
||||
&& parsedOrigin.hash === ''
|
||||
&& parsedOrigin.origin === authority;
|
||||
if (!exactOrigin) {
|
||||
return { ok: false, status: 403, error: 'Cross-origin requests are refused' };
|
||||
}
|
||||
}
|
||||
if (method === 'POST' && !String(contentType || '').toLowerCase().startsWith('application/json')) {
|
||||
return { ok: false, status: 415, error: 'Content-Type must be application/json' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @returns {Set<string>} every env var the panel is allowed to write. */
|
||||
export function knownKeySetupEnvVars() {
|
||||
const names = new Set();
|
||||
for (const entry of KEY_SETUP_KEYS) {
|
||||
for (const envVar of entry.envVars) names.add(envVar);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
* provenance without carrying the credential itself; it closes the otherwise
|
||||
* undecidable case where an exported value and a dotenv assignment happen to
|
||||
* contain the same bytes.
|
||||
* @param {{effectiveValue: unknown, storedValue: unknown, wasExternalAtBoot?: boolean}} input
|
||||
*/
|
||||
export function isKeySetupExternallyManaged({
|
||||
effectiveValue,
|
||||
storedValue,
|
||||
wasExternalAtBoot = false,
|
||||
} = {}) {
|
||||
const effective = String(effectiveValue ?? '').trim();
|
||||
const stored = String(storedValue ?? '').trim();
|
||||
return effective !== '' && (wasExternalAtBoot || effective !== stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the status payload the panel renders from: the registry, plus
|
||||
* per-entry `set` resolved against the given environment. It never includes
|
||||
* a value, suffix, or other credential material.
|
||||
* @param {Record<string, string|undefined>} env e.g. process.env
|
||||
*/
|
||||
export function keySetupStatus(env = {}) {
|
||||
const keys = KEY_SETUP_KEYS.map((entry) => {
|
||||
const values = entry.envVars.map((name) => String(env[name] ?? '').trim());
|
||||
const set = values.every((value) => value.length > 0);
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
unlocks: entry.unlocks,
|
||||
getUrl: entry.getUrl,
|
||||
envVars: [...entry.envVars],
|
||||
tier: entry.tier,
|
||||
clientExposed: Boolean(entry.clientExposed),
|
||||
set,
|
||||
};
|
||||
});
|
||||
return {
|
||||
keys,
|
||||
setCount: keys.filter((key) => key.set).length,
|
||||
total: keys.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a POST body into a clean {ENV_VAR: value} map, or say exactly why
|
||||
* not. Values must be single-line printable ASCII with no spaces — every real
|
||||
* provider credential is — which is also what makes the raw `KEY=value` line
|
||||
* below safe to write without quoting rules. A `null` value means REMOVE:
|
||||
* the writer comments the assignment back out, returning the file to its
|
||||
* template state for that key.
|
||||
* @param {unknown} body Parsed JSON from the request.
|
||||
*/
|
||||
export function validateKeySetupUpdates(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { ok: false, error: 'Body must be a JSON object of {ENV_VAR: value}' };
|
||||
}
|
||||
const entries = Object.entries(body);
|
||||
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
|
||||
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
|
||||
return { ok: false, error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save` };
|
||||
}
|
||||
const known = knownKeySetupEnvVars();
|
||||
const updates = {};
|
||||
for (const [name, raw] of entries) {
|
||||
if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` };
|
||||
if (raw === null) {
|
||||
updates[name] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string') return { ok: false, error: `${name} must be a string` };
|
||||
const value = raw.trim();
|
||||
if (!value) return { ok: false, error: `${name} is empty` };
|
||||
if (value.length > KEY_SETUP_VALUE_LIMIT) {
|
||||
return { ok: false, error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)` };
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(value)) {
|
||||
return { ok: false, error: `${name} may only contain printable characters with no spaces` };
|
||||
}
|
||||
// Reject the dotenv metacharacters that would round-trip WRONG when written
|
||||
// unquoted (# starts a comment, quotes redelimit, $ expands, backslash and
|
||||
// backtick are escapes) — so a saved value can never differ from what Node's
|
||||
// parseEnv and Vite's expansion read back. Real provider keys never contain
|
||||
// these; they are base64url / hex / JWT alphabets.
|
||||
if (/[#"'$\\`]/.test(value)) {
|
||||
return { ok: false, error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)` };
|
||||
}
|
||||
updates[name] = value;
|
||||
}
|
||||
return { ok: true, updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert `KEY=value` lines into dotenv text, disturbing nothing else.
|
||||
*
|
||||
* Placement, per key: the LAST active assignment is replaced in place (last
|
||||
* is what dotenv parsing lets win); failing that, the last commented-out
|
||||
* assignment is uncommented in place, so a file copied from .env.example
|
||||
* keeps its curated shape; failing both, the line is appended at the end
|
||||
* under one shared header. Every untouched line — comments, blanks, other
|
||||
* keys — survives byte for byte, and the result always ends in a newline.
|
||||
*
|
||||
* A `null` value REMOVES: every active assignment for that key is commented
|
||||
* back out (`# KEY=`), returning the file to its template shape; a key with
|
||||
* no active assignment is left untouched.
|
||||
* @param {string} text Existing file content ('' births a new file).
|
||||
* @param {Record<string, string|null>} updates Validated {ENV_VAR: value} map.
|
||||
*/
|
||||
export function upsertDotenvValues(text, updates) {
|
||||
const source = typeof text === 'string' ? text : '';
|
||||
const lines = source.length ? source.split(/\r?\n/) : [];
|
||||
const additions = [];
|
||||
for (const [name, value] of Object.entries(updates)) {
|
||||
const active = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`);
|
||||
const commented = new RegExp(`^\\s*#\\s*(?:export\\s+)?${name}\\s*=`);
|
||||
if (value === null) {
|
||||
lines.forEach((line, index) => {
|
||||
if (active.test(line)) lines[index] = `# ${name}=`;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const assignment = `${name}=${value}`;
|
||||
let lastActive = -1;
|
||||
let lastCommented = -1;
|
||||
lines.forEach((line, index) => {
|
||||
if (active.test(line)) lastActive = index;
|
||||
else if (commented.test(line)) lastCommented = index;
|
||||
});
|
||||
if (lastActive >= 0) lines[lastActive] = assignment;
|
||||
else if (lastCommented >= 0) lines[lastCommented] = assignment;
|
||||
else additions.push(assignment);
|
||||
}
|
||||
if (additions.length) {
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
if (!lines.some((line) => line.trim() === KEY_SETUP_APPEND_HEADER)) {
|
||||
if (lines.length) lines.push('');
|
||||
lines.push(KEY_SETUP_APPEND_HEADER);
|
||||
}
|
||||
lines.push(...additions);
|
||||
}
|
||||
const joined = lines.join('\n');
|
||||
if (!joined) return '';
|
||||
return joined.endsWith('\n') ? joined : `${joined}\n`;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
KEY_SETUP_KEYS,
|
||||
KEY_SETUP_VALUE_LIMIT,
|
||||
commandCompletedSuccessfully,
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
knownKeySetupEnvVars,
|
||||
parseWindowsUserSid,
|
||||
upsertDotenvValues,
|
||||
validateKeySetupUpdates,
|
||||
} from './keySetupCore.mjs';
|
||||
|
||||
test('the boot provenance snapshot survives in-process Vite config re-evaluation', () => {
|
||||
// server.restart() re-evaluates vite.config.js in the SAME process after a
|
||||
// panel save has already set its values live on process.env. A recomputed
|
||||
// snapshot would classify the panel's own keys as external (read-only) until
|
||||
// a full process relaunch, so the first evaluation's snapshot must win.
|
||||
const source = readFileSync(new URL('../vite.config.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
source,
|
||||
/const PROVIDER_ENV_AT_BOOT = globalThis\.__GEV_PROVIDER_ENV_AT_BOOT \?\?= Object\.freeze\(/,
|
||||
);
|
||||
});
|
||||
|
||||
test('external ownership uses boot provenance even when store and shell values match', () => {
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'same-value',
|
||||
storedValue: 'same-value',
|
||||
wasExternalAtBoot: true,
|
||||
}), true, 'equal bytes cannot turn a shell/Keychain value into a file-owned value');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'file-value',
|
||||
storedValue: 'file-value',
|
||||
}), false, 'a value loaded only from the owned store remains editable');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'shell-value',
|
||||
storedValue: 'stale-file-value',
|
||||
}), true, 'a differing live value remains external');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: '',
|
||||
storedValue: 'stale-file-value',
|
||||
wasExternalAtBoot: true,
|
||||
}), false, 'an absent live credential has no external owner');
|
||||
});
|
||||
|
||||
test('the status payload reports presence without any credential material', () => {
|
||||
const env = {
|
||||
GOOGLE_MAPS_API_KEY: 'AIzaSyFakeFakeFakeFake1234',
|
||||
OPENSKY_CLIENT_ID: 'client-id-abcdef',
|
||||
// Secret missing: the OpenSky pair must read as NOT set.
|
||||
};
|
||||
const status = keySetupStatus(env);
|
||||
assert.equal(status.total, KEY_SETUP_KEYS.length);
|
||||
const google = status.keys.find((key) => key.id === 'google-maps');
|
||||
assert.equal(google.set, true);
|
||||
const opensky = status.keys.find((key) => key.id === 'opensky');
|
||||
assert.equal(opensky.set, false, 'half a credential pair is not configured');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('AIzaSyFakeFakeFakeFake1234'), 'a value leaked into status');
|
||||
assert.ok(!serialized.includes('client-id-abcdef'), 'a value leaked into status');
|
||||
assert.ok(!serialized.includes('1234'), 'a credential suffix leaked into status');
|
||||
assert.ok(!serialized.includes('abcdef'), 'a credential suffix leaked into status');
|
||||
assert.ok(!serialized.includes('tails'), 'status must not expose a credential-tail field');
|
||||
assert.equal(status.setCount, 1);
|
||||
});
|
||||
|
||||
test('whitespace-only env values do not count as configured', () => {
|
||||
const status = keySetupStatus({ OPENAI_API_KEY: ' ' });
|
||||
assert.equal(status.keys.find((key) => key.id === 'openai').set, false);
|
||||
});
|
||||
|
||||
test('subprocess success requires a clean zero exit', () => {
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: null }), true);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 1, signal: null }), false);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), false);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: null, error: new Error('spawn failed') }), false);
|
||||
assert.equal(commandCompletedSuccessfully(null), false);
|
||||
});
|
||||
|
||||
test('Windows owner SID parsing reads only the structured user-SID CSV field', () => {
|
||||
const localUser = 'S-1-5-21-1111111111-2222222222-3333333333-1001';
|
||||
const entraUser = 'S-1-12-1-1111111111-2222222222-3333333333-4444444444';
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), localUser);
|
||||
assert.equal(parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), entraUser);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(`"${localUser}","S-1-5-32-545"`),
|
||||
null,
|
||||
'an SID-looking account name must never be mistaken for the token SID',
|
||||
);
|
||||
assert.equal(parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), null, 'broad group SID refused');
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`), null);
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), null, 'unterminated CSV refused');
|
||||
});
|
||||
|
||||
test('validation accepts every registry env var and only those', () => {
|
||||
const known = knownKeySetupEnvVars();
|
||||
for (const name of known) {
|
||||
const verdict = validateKeySetupUpdates({ [name]: 'valid-value-123' });
|
||||
assert.equal(verdict.ok, true, `${name} should validate`);
|
||||
assert.equal(verdict.updates[name], 'valid-value-123');
|
||||
}
|
||||
assert.equal(validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, false, 'PATH must be refused');
|
||||
assert.equal(validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, false, 'NODE_OPTIONS must be refused');
|
||||
});
|
||||
|
||||
test('validation trims, and refuses empties, newlines, spaces, and oversize values', () => {
|
||||
const trimmed = validateKeySetupUpdates({ OPENAI_API_KEY: ' sk-abc123 ' });
|
||||
assert.equal(trimmed.ok, true);
|
||||
assert.equal(trimmed.updates.OPENAI_API_KEY, 'sk-abc123');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, false, 'newline injection');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, false, 'inner space');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, false, 'non-ASCII');
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1) }).ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(validateKeySetupUpdates(null).ok, false);
|
||||
assert.equal(validateKeySetupUpdates([]).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({}).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 42 }).ok, false);
|
||||
});
|
||||
|
||||
test('upsert replaces the last active assignment in place', () => {
|
||||
const text = [
|
||||
'# comment stays',
|
||||
'OPENAI_API_KEY=old-one',
|
||||
'PORT=4173',
|
||||
'OPENAI_API_KEY=old-two',
|
||||
'',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { OPENAI_API_KEY: 'new-key' });
|
||||
assert.equal(next, [
|
||||
'# comment stays',
|
||||
'OPENAI_API_KEY=old-one',
|
||||
'PORT=4173',
|
||||
'OPENAI_API_KEY=new-key',
|
||||
'',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('upsert uncomments a commented assignment in place, keeping file shape', () => {
|
||||
const text = [
|
||||
'# Optional: NASA FIRMS live active fires.',
|
||||
'# FIRMS_MAP_KEY=',
|
||||
'',
|
||||
'PORT=4173',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { FIRMS_MAP_KEY: 'firms-123' });
|
||||
assert.equal(next, [
|
||||
'# Optional: NASA FIRMS live active fires.',
|
||||
'FIRMS_MAP_KEY=firms-123',
|
||||
'',
|
||||
'PORT=4173',
|
||||
].join('\n') + '\n');
|
||||
});
|
||||
|
||||
test('upsert appends unknown keys under one shared header, once', () => {
|
||||
const first = upsertDotenvValues('PORT=4173\n', { OPENAI_API_KEY: 'sk-1' });
|
||||
assert.equal(first, [
|
||||
'PORT=4173',
|
||||
'',
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
'OPENAI_API_KEY=sk-1',
|
||||
].join('\n') + '\n');
|
||||
const second = upsertDotenvValues(first, { FIRMS_MAP_KEY: 'f-2' });
|
||||
assert.equal(second, [
|
||||
'PORT=4173',
|
||||
'',
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
'OPENAI_API_KEY=sk-1',
|
||||
'FIRMS_MAP_KEY=f-2',
|
||||
].join('\n') + '\n');
|
||||
assert.equal(second.split(KEY_SETUP_APPEND_HEADER).length, 2, 'header written once');
|
||||
});
|
||||
|
||||
test('upsert births a well-formed file from nothing', () => {
|
||||
const next = upsertDotenvValues('', { GOOGLE_MAPS_API_KEY: 'AIza-x' });
|
||||
assert.equal(next, `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`);
|
||||
});
|
||||
|
||||
test('upsert handles export-prefixed lines and never touches lookalike keys', () => {
|
||||
const text = [
|
||||
'export OPENAI_API_KEY=old',
|
||||
'NOT_OPENAI_API_KEY=keep-me',
|
||||
'OPENAI_API_KEY_MINI=keep-me-too',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { OPENAI_API_KEY: 'new' });
|
||||
const lines = next.split('\n');
|
||||
assert.equal(lines[0], 'OPENAI_API_KEY=new');
|
||||
assert.equal(lines[1], 'NOT_OPENAI_API_KEY=keep-me');
|
||||
assert.equal(lines[2], 'OPENAI_API_KEY_MINI=keep-me-too');
|
||||
});
|
||||
|
||||
test('upsert is idempotent for a repeated save', () => {
|
||||
const once = upsertDotenvValues('', { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' });
|
||||
const twice = upsertDotenvValues(once, { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' });
|
||||
assert.equal(once, twice);
|
||||
});
|
||||
|
||||
test('a real .env.example round-trip: the curated file keeps its shape', () => {
|
||||
// A representative slice of the shipped .env.example.
|
||||
const example = [
|
||||
'# God\'s Eye View — environment variables',
|
||||
'GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here',
|
||||
'',
|
||||
'# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.',
|
||||
'OPENAI_API_KEY=',
|
||||
'OPENAI_REALTIME_MODEL=gpt-realtime-2',
|
||||
'',
|
||||
'# TOMTOM_API_KEY=',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(example, {
|
||||
GOOGLE_MAPS_API_KEY: 'AIza-real',
|
||||
OPENAI_API_KEY: 'sk-real',
|
||||
TOMTOM_API_KEY: 'tt-real',
|
||||
});
|
||||
const lines = next.split('\n');
|
||||
assert.equal(lines[1], 'GOOGLE_MAPS_API_KEY=AIza-real');
|
||||
assert.equal(lines[4], 'OPENAI_API_KEY=sk-real');
|
||||
assert.equal(lines[5], 'OPENAI_REALTIME_MODEL=gpt-realtime-2', 'sibling key untouched');
|
||||
assert.equal(lines[7], 'TOMTOM_API_KEY=tt-real', 'commented key uncommented in place');
|
||||
});
|
||||
|
||||
test('the admission gate refuses every non-local shape, one assertion per refusal', async () => {
|
||||
const { admitKeySetupRequest } = await import('./keySetupCore.mjs');
|
||||
const local = {
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
env: {},
|
||||
};
|
||||
assert.equal(admitKeySetupRequest(local).ok, true, 'the honest local request is admitted');
|
||||
assert.equal(admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }).ok, true, 'local GET needs no content type');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: undefined }).ok, false, 'POST without Origin is refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, method: 'GET', origin: undefined, contentType: undefined }).ok, true, 'local GET may omit Origin');
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '::ffff:127.0.0.1', hostHeader: '[::1]:4173', origin: 'http://[::1]:4173' }).ok, true, 'IPv6 loopback forms are local');
|
||||
|
||||
// Tunnel/LAN sharing of any kind removes the surface outright — tunnel
|
||||
// traffic arrives FROM loopback, so no socket check can carry this boundary.
|
||||
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_CLOUDFLARE: 'true' } }).ok, false, 'sharing disables the surface');
|
||||
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
|
||||
// A LAN peer reaching a wide-bound server.
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
|
||||
// Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket.
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: '' }).ok, false, 'missing Host refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, false, 'malformed bracketed Host refused');
|
||||
// A hostile web page POSTing at localhost carries its own Origin.
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, false, 'cross-origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, false, 'unparseable Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, false, 'cross-port Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host Origin refused');
|
||||
// A simple-request POST (no JSON content type) is the CSRF write shape.
|
||||
const noJson = admitKeySetupRequest({ ...local, contentType: 'text/plain' });
|
||||
assert.equal(noJson.ok, false, 'non-JSON POST refused');
|
||||
assert.equal(noJson.status, 415);
|
||||
});
|
||||
|
||||
test('a null value validates as a removal; an empty string still does not', () => {
|
||||
const removal = validateKeySetupUpdates({ OPENAI_API_KEY: null });
|
||||
assert.equal(removal.ok, true);
|
||||
assert.equal(removal.updates.OPENAI_API_KEY, null);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false, 'empty is a mistake, not a removal');
|
||||
assert.equal(validateKeySetupUpdates({ PATH: null }).ok, false, 'removal is registry-bound too');
|
||||
});
|
||||
|
||||
test('removal comments the assignment back out, returning the file to template shape', () => {
|
||||
const text = [
|
||||
'# Optional: OpenAI Realtime voice control.',
|
||||
'OPENAI_API_KEY=sk-live',
|
||||
'PORT=4173',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { OPENAI_API_KEY: null });
|
||||
const lines = next.split('\n');
|
||||
assert.equal(lines[1], '# OPENAI_API_KEY=', 'active line commented out, not deleted');
|
||||
assert.equal(lines[2], 'PORT=4173', 'neighbors untouched');
|
||||
// Removing a key with no active assignment changes nothing.
|
||||
assert.equal(upsertDotenvValues(next, { FIRMS_MAP_KEY: null }), next);
|
||||
// The commented-out line is reusable: a later save uncomments it in place.
|
||||
const again = upsertDotenvValues(next, { OPENAI_API_KEY: 'sk-new' });
|
||||
assert.equal(again.split('\n')[1], 'OPENAI_API_KEY=sk-new');
|
||||
});
|
||||
|
||||
test('the sharing gate treats a real PINOKIO_SHARE_VAR as sharing, but not the empty/sentinel normal state', async () => {
|
||||
const { admitKeySetupRequest } = await import('./keySetupCore.mjs');
|
||||
const base = {
|
||||
method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json',
|
||||
};
|
||||
// The ordinary launch states: unset, empty, or the explicit disabled sentinel.
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: {} }).ok, true, 'unset SHARE_VAR is normal');
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, true, 'empty SHARE_VAR is normal');
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' } }).ok, true, 'the disabled sentinel is normal');
|
||||
// A real tunnel var disables the surface.
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' } }).ok, false, 'a real share var is sharing');
|
||||
});
|
||||
|
||||
test('the gate refuses proxied requests even from a loopback socket with local headers', async () => {
|
||||
const { admitKeySetupRequest } = await import('./keySetupCore.mjs');
|
||||
const base = {
|
||||
method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json', env: {},
|
||||
};
|
||||
assert.equal(admitKeySetupRequest(base).ok, true, 'no proxy headers → admitted');
|
||||
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }).ok,
|
||||
false,
|
||||
`${header} present → refused`,
|
||||
);
|
||||
}
|
||||
// An empty forwarding header is not a proxy signal.
|
||||
assert.equal(admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
|
||||
});
|
||||
|
||||
test('validation rejects dotenv metacharacters that would round-trip wrong', () => {
|
||||
for (const bad of ['abc#def', 'ab"cd', "ab'cd", 'ab$cd', 'ab\\cd', 'ab`cd']) {
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, false, `${JSON.stringify(bad)} refused`);
|
||||
}
|
||||
// Real key alphabets still pass: base64url, JWT dots, hex, plus/slash.
|
||||
for (const good of ['sk-AbC0-9_x', 'eyJhbGc.eyJzdWI.QWxpY2U', 'a1b2c3d4e5f6', 'AB+cd/ef=']) {
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, true, `${good} accepted`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
commandCompletedSuccessfully,
|
||||
parseWindowsUserSid,
|
||||
} from './keySetupCore.mjs';
|
||||
|
||||
/** PowerShell verification for the exact owner-only Windows credential DACL. */
|
||||
const WINDOWS_ACL_VERIFY_SCRIPT = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'$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')",
|
||||
'$seen = @{}',
|
||||
'$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
||||
'if ($rules.Count -ne 3) { exit 7 }',
|
||||
'foreach ($rule in $rules) {',
|
||||
' $ruleSid = $rule.IdentityReference.Value',
|
||||
' if ($rule.IsInherited) { exit 3 }',
|
||||
' if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { exit 4 }',
|
||||
' if ($allowed -notcontains $ruleSid) { exit 5 }',
|
||||
' if ($seen.ContainsKey($ruleSid)) { exit 8 }',
|
||||
' $full = [System.Security.AccessControl.FileSystemRights]::FullControl',
|
||||
' if ($rule.FileSystemRights -ne $full) { exit 6 }',
|
||||
' $seen[$ruleSid] = $true',
|
||||
'}',
|
||||
'if ($seen.Count -ne 3) { exit 9 }',
|
||||
].join('; ');
|
||||
|
||||
/**
|
||||
* Resolve the native Windows ACL tools without consulting PATH.
|
||||
*
|
||||
* Provider Settings supports the standard Windows installation layout only:
|
||||
* a local drive root named `Windows` (for example C:\\Windows or D:\\Windows).
|
||||
* Requiring consistent aliases, canonical paths, and regular files prevents an
|
||||
* inherited environment override, UNC share, device path, junction, or PATH
|
||||
* shim from being treated as an operating-system security tool.
|
||||
*/
|
||||
function resolveWindowsNativeTools(environment, fileSystem, architecture) {
|
||||
const aliases = ['SystemRoot', 'SYSTEMROOT', 'WINDIR', 'windir'];
|
||||
const configured = aliases
|
||||
.map((name) => environment[name])
|
||||
.filter((value) => typeof value === 'string' && value.length > 0);
|
||||
if (configured.length === 0) return null;
|
||||
|
||||
const roots = configured.map((value) => {
|
||||
if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value)) return null;
|
||||
return value.endsWith('\\') ? value.slice(0, -1) : value;
|
||||
});
|
||||
if (roots.some((root) => !root)) return null;
|
||||
if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase())) return null;
|
||||
|
||||
const systemRoot = roots[0];
|
||||
const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32';
|
||||
const expected = {
|
||||
whoami: path.win32.join(systemRoot, systemDirectory, 'whoami.exe'),
|
||||
icacls: path.win32.join(systemRoot, systemDirectory, 'icacls.exe'),
|
||||
powershell: path.win32.join(
|
||||
systemRoot,
|
||||
systemDirectory,
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe',
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
const realpath = fileSystem.realpathSync.native || fileSystem.realpathSync;
|
||||
const rootEntry = fileSystem.lstatSync(systemRoot);
|
||||
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) return null;
|
||||
const canonicalRoot = realpath.call(fileSystem.realpathSync, systemRoot);
|
||||
if (canonicalRoot.toLowerCase() !== systemRoot.toLowerCase()) return null;
|
||||
for (const executable of Object.values(expected)) {
|
||||
const entry = fileSystem.lstatSync(executable);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) return null;
|
||||
const canonicalExecutable = realpath.call(fileSystem.realpathSync, executable);
|
||||
const canonicalCandidates = [executable];
|
||||
if (architecture === 'ia32') {
|
||||
canonicalCandidates.push(executable.replace('\\Sysnative\\', '\\System32\\'));
|
||||
}
|
||||
if (!canonicalCandidates.some(
|
||||
(candidate) => candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
|
||||
)) return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a credential file before any secret is written to it.
|
||||
* Dependencies are injectable so every fail-closed branch is unit-testable.
|
||||
*/
|
||||
export function hardenCredentialFile(filepath, {
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
spawn = spawnSync,
|
||||
fileSystem = fs,
|
||||
environment = process.env,
|
||||
} = {}) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
const aclRemoval = spawn('chmod', ['-N', filepath], { stdio: 'ignore' });
|
||||
if (!commandCompletedSuccessfully(aclRemoval)) return false;
|
||||
}
|
||||
fileSystem.chmodSync(filepath, 0o600);
|
||||
return (fileSystem.statSync(filepath).mode & 0o777) === 0o600;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const tools = resolveWindowsNativeTools(environment, fileSystem, architecture);
|
||||
if (!tools) return false;
|
||||
|
||||
try {
|
||||
// Grant by the CURRENT PROCESS TOKEN'S SID, never a bare username. Parsing
|
||||
// the second CSV field structurally prevents an SID-looking account name or
|
||||
// a broad group SID from becoming the credential owner.
|
||||
const whoami = spawn(tools.whoami, ['/user', '/fo', 'csv', '/nh'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
const sid = commandCompletedSuccessfully(whoami)
|
||||
? parseWindowsUserSid(whoami.stdout)
|
||||
: null;
|
||||
if (!sid) return false;
|
||||
|
||||
const applied = spawn(tools.icacls, [
|
||||
filepath,
|
||||
'/inheritance:r',
|
||||
'/grant:r',
|
||||
`*${sid}:F`,
|
||||
'*S-1-5-18:F',
|
||||
'*S-1-5-32-544:F',
|
||||
], { stdio: 'ignore', windowsHide: true });
|
||||
if (!commandCompletedSuccessfully(applied)) return false;
|
||||
|
||||
// Command success is not proof of the resulting DACL. Query it back and
|
||||
// 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.
|
||||
const verified = spawn(tools.powershell, [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
|
||||
], {
|
||||
env: {
|
||||
...environment,
|
||||
GEV_ACL_FILE: filepath,
|
||||
GEV_ACL_USER_SID: sid,
|
||||
},
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return commandCompletedSuccessfully(verified);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { hardenCredentialFile } from './keySetupHardening.mjs';
|
||||
|
||||
const FILE = path.join(os.tmpdir(), 'provider-settings-test');
|
||||
const USER_SID = 'S-1-5-21-1111111111-2222222222-3333333333-1001';
|
||||
const WINDOWS_ROOT = 'C:\\Windows';
|
||||
|
||||
function windowsFileSystem({
|
||||
root = WINDOWS_ROOT,
|
||||
systemDirectory = 'System32',
|
||||
realpaths = {},
|
||||
missing = [],
|
||||
symlinks = [],
|
||||
} = {}) {
|
||||
const tools = [
|
||||
`${root}\\${systemDirectory}\\whoami.exe`,
|
||||
`${root}\\${systemDirectory}\\icacls.exe`,
|
||||
`${root}\\${systemDirectory}\\WindowsPowerShell\\v1.0\\powershell.exe`,
|
||||
];
|
||||
const realpathSync = (filepath) => realpaths[filepath] || filepath;
|
||||
realpathSync.native = realpathSync;
|
||||
return {
|
||||
realpathSync,
|
||||
lstatSync(filepath) {
|
||||
return {
|
||||
isDirectory: () => filepath === root,
|
||||
isFile: () => tools.includes(filepath) && !missing.includes(filepath),
|
||||
isSymbolicLink: () => symlinks.includes(filepath),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fileSystemWithMode(mode = 0o600) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
chmodSync(filepath, nextMode) { calls.push(['chmod', filepath, nextMode]); },
|
||||
statSync(filepath) { calls.push(['stat', filepath]); return { mode }; },
|
||||
};
|
||||
}
|
||||
|
||||
test('macOS ACL removal failure stops before chmod and fails closed', () => {
|
||||
const fileSystem = fileSystemWithMode();
|
||||
const result = hardenCredentialFile(FILE, {
|
||||
platform: 'darwin',
|
||||
fileSystem,
|
||||
spawn: () => ({ status: 1, signal: null }),
|
||||
});
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(fileSystem.calls, [], 'mode bits must not disguise an ACL-removal failure');
|
||||
});
|
||||
|
||||
test('POSIX hardening verifies the resulting 0600 mode', () => {
|
||||
const goodFs = fileSystemWithMode(0o100600);
|
||||
assert.equal(hardenCredentialFile(FILE, {
|
||||
platform: 'darwin',
|
||||
fileSystem: goodFs,
|
||||
spawn: () => ({ status: 0, signal: null }),
|
||||
}), true);
|
||||
assert.deepEqual(goodFs.calls, [['chmod', FILE, 0o600], ['stat', FILE]]);
|
||||
|
||||
const broadFs = fileSystemWithMode(0o100640);
|
||||
assert.equal(hardenCredentialFile(FILE, {
|
||||
platform: 'linux',
|
||||
fileSystem: broadFs,
|
||||
spawn: () => { throw new Error('Linux must not spawn chmod'); },
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('Windows hardening refuses an unstructured or broad owner SID before icacls', () => {
|
||||
const commands = [];
|
||||
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: { SYSTEMROOT: WINDOWS_ROOT },
|
||||
fileSystem: windowsFileSystem(),
|
||||
spawn(command) {
|
||||
commands.push(command);
|
||||
return { status: 0, signal: null, stdout: '"S-1-5-21-1-2-3-1001","S-1-5-32-545"' };
|
||||
},
|
||||
});
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(commands, [`${WINDOWS_ROOT}\\System32\\whoami.exe`]);
|
||||
});
|
||||
|
||||
test('Windows hardening applies and then verifies the exact restricted DACL', () => {
|
||||
const calls = [];
|
||||
const filepath = 'C:\\GEV App\\pinokio\\ENVIRONMENT.tmp';
|
||||
const result = hardenCredentialFile(filepath, {
|
||||
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}"\r\n` };
|
||||
}
|
||||
return { status: 0, signal: null };
|
||||
},
|
||||
});
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls.map(({ command }) => command), [
|
||||
`${WINDOWS_ROOT}\\System32\\whoami.exe`,
|
||||
`${WINDOWS_ROOT}\\System32\\icacls.exe`,
|
||||
`${WINDOWS_ROOT}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
|
||||
]);
|
||||
assert.deepEqual(calls[1].args, [
|
||||
filepath,
|
||||
'/inheritance:r',
|
||||
'/grant:r',
|
||||
`*${USER_SID}:F`,
|
||||
'*S-1-5-18:F',
|
||||
'*S-1-5-32-544:F',
|
||||
]);
|
||||
assert.equal(calls[1].args.filter((arg) => arg === '/grant:r').length, 1);
|
||||
assert.equal(calls[2].options.env.GEV_ACL_FILE, filepath);
|
||||
assert.equal(calls[2].options.env.GEV_ACL_USER_SID, USER_SID);
|
||||
assert.match(calls[2].args.at(-1), /AreAccessRulesProtected/);
|
||||
assert.match(calls[2].args.at(-1), /rules\.Count -ne 3/);
|
||||
assert.match(calls[2].args.at(-1), /seen\.ContainsKey/);
|
||||
assert.match(calls[2].args.at(-1), /FileSystemRights -ne \$full/);
|
||||
assert.match(calls[2].args.at(-1), /seen\.Count -ne 3/);
|
||||
});
|
||||
|
||||
test('Windows hardening bypasses PATH-shadowed native ACL tools', () => {
|
||||
const commands = [];
|
||||
const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: {
|
||||
PATH: 'D:\\pinokio\\bin;C:\\Windows\\System32',
|
||||
SystemRoot: WINDOWS_ROOT,
|
||||
WINDIR: 'c:\\windows\\',
|
||||
},
|
||||
fileSystem: windowsFileSystem(),
|
||||
spawn(command) {
|
||||
commands.push(command);
|
||||
if (command.endsWith('\\whoami.exe')) {
|
||||
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
|
||||
}
|
||||
return { status: 0, signal: null };
|
||||
},
|
||||
});
|
||||
assert.equal(result, true);
|
||||
assert.equal(commands.some((command) => !command.startsWith(`${WINDOWS_ROOT}\\System32\\`)), false);
|
||||
});
|
||||
|
||||
test('Windows hardening rejects redirected or ambiguous system roots before spawning', () => {
|
||||
const rejected = [
|
||||
{},
|
||||
{ SYSTEMROOT: 'Windows' },
|
||||
{ SYSTEMROOT: '\\Windows' },
|
||||
{ SYSTEMROOT: '\\\\server\\share\\Windows' },
|
||||
{ SYSTEMROOT: '\\\\?\\C:\\Windows' },
|
||||
{ SYSTEMROOT: 'C:\\Temp\\..\\Windows' },
|
||||
{ SYSTEMROOT: 'C:\\attacker\\Windows' },
|
||||
{ SYSTEMROOT: WINDOWS_ROOT, WINDIR: 'D:\\Windows' },
|
||||
];
|
||||
for (const environment of rejected) {
|
||||
let spawned = false;
|
||||
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment,
|
||||
fileSystem: windowsFileSystem(),
|
||||
spawn() { spawned = true; return { status: 0, signal: null }; },
|
||||
});
|
||||
assert.equal(result, false, JSON.stringify(environment));
|
||||
assert.equal(spawned, false, JSON.stringify(environment));
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows hardening accepts a canonical Windows root on a non-default drive', () => {
|
||||
const root = 'D:\\Windows';
|
||||
const commands = [];
|
||||
const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: { SYSTEMROOT: root },
|
||||
fileSystem: windowsFileSystem({ root }),
|
||||
spawn(command) {
|
||||
commands.push(command);
|
||||
if (command.endsWith('\\whoami.exe')) {
|
||||
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
|
||||
}
|
||||
return { status: 0, signal: null };
|
||||
},
|
||||
});
|
||||
assert.equal(result, true);
|
||||
assert.equal(commands.every((command) => command.startsWith('D:\\Windows\\System32\\')), true);
|
||||
});
|
||||
|
||||
test('32-bit Windows hardening uses the native Sysnative bridge', () => {
|
||||
const commands = [];
|
||||
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
architecture: 'ia32',
|
||||
environment: { SYSTEMROOT: WINDOWS_ROOT },
|
||||
fileSystem: windowsFileSystem({ systemDirectory: 'Sysnative' }),
|
||||
spawn(command) {
|
||||
commands.push(command);
|
||||
if (command.endsWith('\\whoami.exe')) {
|
||||
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
|
||||
}
|
||||
return { status: 0, signal: null };
|
||||
},
|
||||
});
|
||||
assert.equal(result, true);
|
||||
assert.equal(commands.every((command) => command.includes('\\Sysnative\\')), true);
|
||||
});
|
||||
|
||||
test('Windows hardening rejects missing, redirected, or non-file native tools', () => {
|
||||
const whoami = `${WINDOWS_ROOT}\\System32\\whoami.exe`;
|
||||
const cases = [
|
||||
windowsFileSystem({ missing: [whoami] }),
|
||||
windowsFileSystem({ realpaths: { [WINDOWS_ROOT]: 'C:\\RedirectedWindows' } }),
|
||||
windowsFileSystem({ realpaths: { [whoami]: 'C:\\attacker\\whoami.exe' } }),
|
||||
windowsFileSystem({ realpaths: { [whoami]: 'C:\\Windows\\Temp\\evil-whoami.exe' } }),
|
||||
windowsFileSystem({ symlinks: [whoami] }),
|
||||
];
|
||||
for (const fileSystem of cases) {
|
||||
let spawned = false;
|
||||
assert.equal(hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: { SYSTEMROOT: WINDOWS_ROOT },
|
||||
fileSystem,
|
||||
spawn() { spawned = true; return { status: 0, signal: null }; },
|
||||
}), false);
|
||||
assert.equal(spawned, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows hardening fails closed when ACL application or verification fails', () => {
|
||||
for (const failingCommand of ['icacls.exe', 'powershell.exe']) {
|
||||
const calls = [];
|
||||
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: { SYSTEMROOT: WINDOWS_ROOT },
|
||||
fileSystem: windowsFileSystem(),
|
||||
spawn(command) {
|
||||
calls.push(command);
|
||||
if (command.endsWith('\\whoami.exe')) {
|
||||
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
|
||||
}
|
||||
return { status: command.endsWith(`\\${failingCommand}`) ? 1 : 0, signal: null };
|
||||
},
|
||||
});
|
||||
assert.equal(result, false, `${failingCommand} failure must refuse the write`);
|
||||
assert.equal(calls.at(-1).endsWith(`\\${failingCommand}`), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows hardening converts subprocess exceptions into a fail-closed result', () => {
|
||||
assert.equal(hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
|
||||
platform: 'win32',
|
||||
environment: { SYSTEMROOT: WINDOWS_ROOT },
|
||||
fileSystem: windowsFileSystem(),
|
||||
spawn() { throw new Error('subprocess unavailable'); },
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('Windows production hardener applies its exact DACL with native tools', {
|
||||
skip: process.platform !== 'win32',
|
||||
}, () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-provider-acl-'));
|
||||
const filepath = path.join(directory, 'ENVIRONMENT.tmp');
|
||||
try {
|
||||
fs.writeFileSync(filepath, '');
|
||||
assert.equal(hardenCredentialFile(filepath), true);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -44,12 +44,7 @@ function terminalFromParticipantStats(summary, participantIds) {
|
||||
if (!participantIds?.length) return null;
|
||||
const participants = new Set(participantIds);
|
||||
return summary.records.some((record) => participants.has(record.id)
|
||||
// A deliberately unconfigured optional provider is a truthful terminal
|
||||
// state for that row, not a failed multi-layer mission. The layer keeps
|
||||
// owning its KEY REQUIRED copy; explicit lifecycle failure events still
|
||||
// merge in below and retain error priority.
|
||||
&& !record.keyRequired
|
||||
&& (record.error || record.unavailable))
|
||||
&& (record.error || record.unavailable || record.keyRequired))
|
||||
? 'error'
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ test('AIS first-connect grace expiry reports failure even without a terminal man
|
||||
assert.equal(presentLoadingFeedback(state, unavailable, 300).label, 'LOAD FAILED');
|
||||
});
|
||||
|
||||
test('an explicitly missing optional key completes the global loading batch honestly', () => {
|
||||
test('participant stats failure outranks a simultaneous visibility completion', () => {
|
||||
const enabling = aggregateLayerLoading([{
|
||||
id: 'ais-live-vessels',
|
||||
name: 'AIS Vessels',
|
||||
@@ -307,31 +307,6 @@ test('an explicitly missing optional key completes the global loading batch hone
|
||||
type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
|
||||
});
|
||||
|
||||
assert.equal(state.terminal, 'complete');
|
||||
assert.equal(presentLoadingFeedback(state, missingKey, 250).label, 'LOAD COMPLETE');
|
||||
});
|
||||
|
||||
test('an explicit lifecycle failure still outranks a key-required row', () => {
|
||||
const enabling = aggregateLayerLoading([{
|
||||
id: 'local-firms',
|
||||
name: 'FIRMS Active Fires',
|
||||
lifecycleState: 'enabling',
|
||||
stats: { loading: true },
|
||||
}]);
|
||||
let state = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
|
||||
state = reduceLoadingFeedback(state, enabling, 200);
|
||||
|
||||
const missingKey = aggregateLayerLoading([{
|
||||
id: 'local-firms',
|
||||
name: 'FIRMS Active Fires',
|
||||
enabled: true,
|
||||
lifecycleState: 'enabled',
|
||||
stats: { loading: false, keyRequired: true, error: 'KEY REQUIRED' },
|
||||
}]);
|
||||
state = reduceLoadingFeedback(state, missingKey, 250, {
|
||||
type: 'visibility-failed', layerId: 'local-firms', error: new Error('lifecycle failed'),
|
||||
});
|
||||
|
||||
assert.equal(state.terminal, 'error');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -696,7 +696,7 @@ export function placeFramingViewport(viewport, anchorLat, anchorLng, types = [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural-region framing heuristic (field test 2026-07-23). Pure — exported
|
||||
* Natural-region framing heuristic (owner field test 2026-07-23). Pure — exported
|
||||
* for unit tests. Given geocode {southwest,northeast} bounds, decide whether to
|
||||
* frame the full viewport or a capped oblique swath over the feature's center,
|
||||
* looking along the feature's long axis.
|
||||
|
||||
@@ -116,7 +116,7 @@ test('admin types win over area types when both present', () => {
|
||||
assert.equal(geocodeNavigationMode(['locality', 'park', 'political']), 'city-overview');
|
||||
});
|
||||
|
||||
// Natural-region framing heuristic (field test 2026-07-23): "take me to the
|
||||
// Natural-region framing heuristic (owner field test 2026-07-23): "take me to the
|
||||
// Rocky Mountains" geocodes as natural_feature with a ~2,700 km viewport — framing
|
||||
// the whole box flies the camera to space. Region-scale viewports get a capped
|
||||
// oblique swath instead; ordinary parks/lakes keep full-viewport framing.
|
||||
@@ -474,7 +474,7 @@ test('regionFramingPlan: invalid viewports return null', () => {
|
||||
assert.equal(regionFramingPlan({ southwest: { lat: NaN, lng: 0 }, northeast: { lat: 1, lng: 1 } }), null);
|
||||
});
|
||||
|
||||
// Globe-view preset (field test 2026-07-23): "zoom out to a globe view" needs an
|
||||
// Globe-view preset (owner field test 2026-07-23): "zoom out to a globe view" needs an
|
||||
// ABSOLUTE full-earth framing — the relative zoom tool can never reach it. The preset
|
||||
// must sit inside the app's own 'global' view-scale band (>12,000 km camera height,
|
||||
// classifyViewScale in gevActions.js) and under the fly_to rangeM ceiling (20,000 km).
|
||||
|
||||
+30
-28
@@ -32,6 +32,8 @@ import {
|
||||
} from './renderGovernor.js';
|
||||
import { installScopeMask } from './scopeMask.js';
|
||||
import { initFirstRunExperience } from './firstRunExperience.js';
|
||||
import { initKeySetup } from './keySetup.js';
|
||||
import { loadPhotorealisticTileset } from './mapStartup.js';
|
||||
|
||||
initLogoGaze();
|
||||
|
||||
@@ -73,21 +75,11 @@ async function init() {
|
||||
try {
|
||||
loaderStatus.textContent = 'Configuring viewer...';
|
||||
|
||||
// Set Cesium Ion token for World Terrain
|
||||
// A direct Google key provides Google 3D plus GEV place search. Cesium ion
|
||||
// can host the same 3D tiles and also powers Bing/world-terrain stacks.
|
||||
const cesiumToken = import.meta.env.CESIUM_ION_TOKEN;
|
||||
if (cesiumToken) {
|
||||
Cesium.Ion.defaultAccessToken = cesiumToken;
|
||||
}
|
||||
|
||||
// Set Google Maps API key for 3D Tiles
|
||||
const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!googleApiKey) {
|
||||
throw new Error('GOOGLE_MAPS_API_KEY not found. Set it as an environment variable.');
|
||||
}
|
||||
Cesium.GoogleMaps.defaultApiKey = googleApiKey;
|
||||
|
||||
// Expose API key globally for geocoding in locations.js
|
||||
window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
|
||||
if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
|
||||
|
||||
// Create the Cesium viewer with minimal chrome
|
||||
const viewer = new Cesium.Viewer('cesiumContainer', {
|
||||
@@ -137,7 +129,7 @@ async function init() {
|
||||
// Required by each source's license (ODbL, CC BY-NC-SA, NASA FIRMS, etc.);
|
||||
// strings are verbatim from DATA_SOURCES.md. Static + always-present in the
|
||||
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
|
||||
// clutter the on-globe attribution line.
|
||||
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
|
||||
registerDataCredits(viewer);
|
||||
|
||||
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
|
||||
@@ -153,22 +145,27 @@ async function init() {
|
||||
viewer.scene.skyAtmosphere.saturationShift = -0.12;
|
||||
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
|
||||
|
||||
loaderStatus.textContent = 'Loading Google 3D Tiles...';
|
||||
let tileset = null;
|
||||
try {
|
||||
// Load Google Photorealistic 3D Tiles
|
||||
tileset = await Cesium.createGooglePhotorealistic3DTileset({
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
loaderStatus.textContent = googleApiKey || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
if (tileset) {
|
||||
viewer.scene.primitives.add(tileset);
|
||||
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
|
||||
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
|
||||
viewer.scene.globe.show = false;
|
||||
} catch (tileError) {
|
||||
console.warn('[Init] Google 3D Tiles unavailable, falling back to Cesium globe:', tileError);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Continuing in fallback mode...`;
|
||||
// Keep Cesium globe visible as fallback instead of aborting the app.
|
||||
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
|
||||
} else {
|
||||
if (photoreal.errors.length) {
|
||||
const tileError = photoreal.errors.at(-1);
|
||||
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
|
||||
}
|
||||
viewer.scene.globe.show = true;
|
||||
}
|
||||
|
||||
@@ -177,7 +174,7 @@ async function init() {
|
||||
const mapStackController = new MapStackController(viewer, {
|
||||
googleTileset: tileset,
|
||||
cesiumToken,
|
||||
initialStack: tileset ? 'photoreal' : 'osm',
|
||||
initialStack: tileset ? 'photoreal' : 'esri-imagery',
|
||||
// Task 5 (height-datum fix): rebroadcast stack changes as a window
|
||||
// CustomEvent so data layers (CCTV per-regime ground resolution) can
|
||||
// react without coupling MapStackController to layer modules. Fires on
|
||||
@@ -188,7 +185,7 @@ async function init() {
|
||||
},
|
||||
onError: (message) => console.warn('[MapStack]', message),
|
||||
});
|
||||
await mapStackController.setStack(tileset ? 'photoreal' : 'osm', { silent: true });
|
||||
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true });
|
||||
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new StyleManager(viewer, { mapStackController });
|
||||
@@ -270,6 +267,11 @@ async function init() {
|
||||
setTimeout(revealFirstRun, 900);
|
||||
});
|
||||
|
||||
// Provider Settings (the POWER UP chip + dialog). Fire-and-forget: the
|
||||
// module removes its own surface when the dev-server endpoint is absent
|
||||
// (prod builds, non-local visitors), so this costs prod exactly nothing.
|
||||
void initKeySetup();
|
||||
|
||||
// Expose for debugging
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// MAP STACK source chips — the always-visible replacement for the `<select>`
|
||||
// that used to sit in the Map Stack panel. One button per stack, rendered from
|
||||
// `MapStackController.getStacks()`. The four accepted sources below are
|
||||
// `MapStackController.getStacks()`. The four owner-approved sources below are
|
||||
// the whole shipped set; keeping the allowlist explicit means a stack added to
|
||||
// `MAP_STACKS` for internal use cannot reach the tray until someone names it
|
||||
// here.
|
||||
@@ -15,6 +15,7 @@ export const PRESENTED_MAP_STACK_IDS = Object.freeze([
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
]);
|
||||
|
||||
|
||||
+56
-12
@@ -1,8 +1,8 @@
|
||||
// MAP STACK chip row — the dropdown's replacement control surface.
|
||||
//
|
||||
// The product issue was two clicks (open panel → open dropdown) to change
|
||||
// The owner's complaint was two clicks (open panel → open dropdown) to change
|
||||
// basemap. These tests pin the three things that make the row a faithful swap:
|
||||
// it projects the accepted four-source allowlist from the controller's
|
||||
// it projects the owner-approved four-source allowlist from the controller's
|
||||
// stack data, a click dispatches the same selection the `change` handler used
|
||||
// to, and the lit chip tracks controller state rather than the click. Run with:
|
||||
// npm test
|
||||
@@ -66,20 +66,21 @@ const CONTROLLER_STACKS = [
|
||||
{ id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
|
||||
{ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
|
||||
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
|
||||
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
|
||||
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
|
||||
];
|
||||
|
||||
test('the row renders exactly the four accepted sources', () => {
|
||||
test('the row renders exactly the five owner-approved sources', () => {
|
||||
const container = makeElement();
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
|
||||
|
||||
assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'osm',
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
||||
]);
|
||||
assert.deepEqual(container.children.map(chipText), [
|
||||
'Google 3D', 'Bing Aerial', 'Bing Labels', 'OSM',
|
||||
'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
|
||||
]);
|
||||
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'osm']);
|
||||
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
|
||||
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
|
||||
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
|
||||
});
|
||||
@@ -87,11 +88,11 @@ test('the row renders exactly the four accepted sources', () => {
|
||||
test('internal and future stacks stay outside the approved presentation set', () => {
|
||||
const container = makeElement();
|
||||
// A future Hybrid stack may land in the controller, but it must not appear
|
||||
// until the accepted presentation allowlist explicitly includes it.
|
||||
// until the owner-approved presentation allowlist explicitly includes it.
|
||||
const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
|
||||
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
|
||||
|
||||
assert.equal(container.children.length, 4);
|
||||
assert.equal(container.children.length, 5);
|
||||
assert.doesNotMatch(container.children.map(chipText).join(' '), /Hybrid/);
|
||||
});
|
||||
|
||||
@@ -112,9 +113,10 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
|
||||
doc,
|
||||
});
|
||||
|
||||
container.children[4].click();
|
||||
container.children[3].click();
|
||||
container.children[1].click();
|
||||
assert.deepEqual(selected, ['osm', 'bing-aerial']);
|
||||
assert.deepEqual(selected, ['osm', 'esri-imagery', 'bing-aerial']);
|
||||
});
|
||||
|
||||
test('the active chip is the pressed chip, and exactly one is pressed', () => {
|
||||
@@ -136,12 +138,12 @@ test('the lit chip tracks controller state, not the click', () => {
|
||||
// A rejected/superseded switch reports the stack that is genuinely active.
|
||||
syncMapStackChips(container, 'photoreal');
|
||||
assert.ok(container.children[0].classList.contains('active'));
|
||||
assert.equal(container.children[3].getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(container.children[4].getAttribute('aria-pressed'), 'false');
|
||||
|
||||
// A landed switch moves both the class and the pressed state.
|
||||
syncMapStackChips(container, 'osm');
|
||||
assert.ok(container.children[3].classList.contains('active'));
|
||||
assert.equal(container.children[3].getAttribute('aria-pressed'), 'true');
|
||||
assert.ok(container.children[4].classList.contains('active'));
|
||||
assert.equal(container.children[4].getAttribute('aria-pressed'), 'true');
|
||||
assert.ok(!container.children[0].classList.contains('active'));
|
||||
assert.equal(container.children[0].getAttribute('aria-pressed'), 'false');
|
||||
});
|
||||
@@ -354,4 +356,46 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
||||
/_renderMapStackState\(state\) \{[\s\S]*?syncMapStackChips\(this\._mapStackChips, state\.activeId\)/,
|
||||
'the active chip must be re-synced from controller state',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.addEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
|
||||
'provider-driven fallback must re-sync the UI without a user click',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.removeEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
|
||||
'the provider-driven state listener must be released with StyleManager',
|
||||
);
|
||||
});
|
||||
|
||||
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
controller,
|
||||
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
|
||||
'construction failure must resolve truthfully to OSM',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
|
||||
'the active chip must follow the effective provider, not the requested stack',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
|
||||
'Esri credit must follow the effective provider',
|
||||
);
|
||||
});
|
||||
|
||||
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(controller, /let failures = 0/);
|
||||
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
|
||||
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
|
||||
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
|
||||
assert.match(
|
||||
controller,
|
||||
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
|
||||
'a stale provider error must not replace a newer user selection',
|
||||
);
|
||||
});
|
||||
+135
-10
@@ -25,6 +25,13 @@ export const MAP_STACKS = [
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
@@ -36,12 +43,27 @@ export const MAP_STACKS = [
|
||||
|
||||
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
|
||||
|
||||
// Esri World Imagery — the keyless satellite basemap and the default keyless
|
||||
// landing (a spy-satellite simulator should open on satellite imagery, not a
|
||||
// street map). The classic ArcGIS Online tile service answers without a key;
|
||||
// attribution is required and the provider carries the service's own credit
|
||||
// line. Terms note in DATA_SOURCES.md.
|
||||
const ESRI_WORLD_IMAGERY_URL =
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
|
||||
const ESRI_IMAGERY_CREDIT =
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
|
||||
// The on-screen notice Esri requires when a third-party library draws its
|
||||
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
|
||||
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
|
||||
const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
|
||||
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
|
||||
// regime C (keyless globe stacks previously rendered a flat
|
||||
// EllipsoidTerrainProvider — see the height-datum contract in docs/CURRENT-STATE.md
|
||||
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
|
||||
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
|
||||
// URL (spec correction, spec §1a).
|
||||
// URL (review correction, spec §1a).
|
||||
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
|
||||
|
||||
/**
|
||||
@@ -62,8 +84,11 @@ export class MapStackController {
|
||||
this.cesiumToken = String(cesiumToken || '').trim();
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._activeId = googleTileset ? initialStack : 'osm';
|
||||
this._activeId = googleTileset ? initialStack : 'esri-imagery';
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._esriFallbackPending = false;
|
||||
this._imageryProviders = new Map();
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
@@ -89,7 +114,7 @@ export class MapStackController {
|
||||
this._switchGen = 0;
|
||||
|
||||
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
|
||||
this._activeId = googleTileset ? 'photoreal' : 'osm';
|
||||
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,15 +196,20 @@ export class MapStackController {
|
||||
if (!silent) this._emitChange('switching');
|
||||
|
||||
try {
|
||||
let activation = null;
|
||||
if (stack.kind === 'photoreal') {
|
||||
await this._activatePhotoreal(gen);
|
||||
} else {
|
||||
await this._activateGlobeStack(stack, gen);
|
||||
activation = await this._activateGlobeStack(stack, gen);
|
||||
}
|
||||
// A newer switch started while we were awaiting the provider — that call
|
||||
// owns the final state now, so don't commit ours or emit a stale 'ready'.
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = stack.id;
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
|
||||
// subsequent tile loads self-request via Cesium. (perf wave 2)
|
||||
governorRequestRender('map-stack');
|
||||
@@ -217,6 +247,7 @@ export class MapStackController {
|
||||
|
||||
async _activatePhotoreal(gen) {
|
||||
this._removeImageryLayer();
|
||||
this._syncEsriAttribution(null); // Esri is no longer on screen.
|
||||
if (this.googleTileset) this.googleTileset.show = true;
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
|
||||
@@ -234,18 +265,51 @@ export class MapStackController {
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const provider = await this._getImageryProvider(stack);
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
// A newer switch started while the provider was resolving — don't touch the
|
||||
// scene's imagery layers, the winning switch already owns them (M7).
|
||||
if (gen != null && gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
|
||||
this._imageryLayer = new Cesium.ImageryLayer(provider);
|
||||
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
this._syncEsriAttribution(resolution.effectiveStackId);
|
||||
this._watchEsriProvider(resolution, gen);
|
||||
|
||||
if (this.googleTileset) this.googleTileset.show = false;
|
||||
this.viewer.scene.globe.show = true;
|
||||
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the required "Powered by Esri" notice with the Esri layer's
|
||||
* own lifecycle.
|
||||
*
|
||||
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
|
||||
* option for tiled ArcGIS MapServer sources, so passing it there displays
|
||||
* nothing and the app would be using the service without the attribution
|
||||
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
|
||||
* lightbox, where per-layer data credits live) because that is what the
|
||||
* requirement asks for, and it is removed when another stack takes over so
|
||||
* the globe never claims a source it is not showing.
|
||||
*/
|
||||
_syncEsriAttribution(activeStackId) {
|
||||
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!creditDisplay) return;
|
||||
const wanted = activeStackId === 'esri-imagery';
|
||||
if (wanted === !!this._esriCreditShown) return;
|
||||
if (!this._esriCredit) {
|
||||
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
|
||||
}
|
||||
try {
|
||||
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
|
||||
else creditDisplay.removeStaticCredit(this._esriCredit);
|
||||
this._esriCreditShown = wanted;
|
||||
} catch {
|
||||
// A Cesium build without static-credit removal must not break switching.
|
||||
}
|
||||
}
|
||||
|
||||
async _getImageryProvider(stack) {
|
||||
@@ -254,8 +318,29 @@ export class MapStackController {
|
||||
}
|
||||
|
||||
let provider;
|
||||
let effectiveStackId = stack.id;
|
||||
let fallbackMessage = null;
|
||||
if (stack.kind === 'ion') {
|
||||
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
|
||||
} else if (stack.kind === 'esri-imagery') {
|
||||
try {
|
||||
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
|
||||
credit: ESRI_IMAGERY_CREDIT,
|
||||
enablePickFeatures: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// The keyless DEFAULT landing must never strand a first run on a blank
|
||||
// globe because Esri is unreachable — fall back to OSM tiles for this
|
||||
// session. (The fallback is cached under this stack id like any other
|
||||
// provider, so the session won't re-probe Esri; a restart does.)
|
||||
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: DEFAULT_OSM_CREDIT,
|
||||
});
|
||||
effectiveStackId = 'osm';
|
||||
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
|
||||
}
|
||||
} else if (stack.kind === 'osm') {
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
@@ -265,14 +350,54 @@ export class MapStackController {
|
||||
throw new Error(`Unsupported map stack: ${stack.id}`);
|
||||
}
|
||||
|
||||
this._imageryProviders.set(stack.id, provider);
|
||||
return provider;
|
||||
const resolution = { provider, effectiveStackId, fallbackMessage };
|
||||
this._imageryProviders.set(stack.id, resolution);
|
||||
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
|
||||
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Esri provider construction can succeed while its first tile requests fail.
|
||||
* Two failures for the active provider trigger the same truthful OSM fallback
|
||||
* as a construction failure; one transient error is left to Cesium's retry.
|
||||
*/
|
||||
_watchEsriProvider(resolution, gen) {
|
||||
if (resolution.effectiveStackId !== 'esri-imagery') return;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures = Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < 2 || this._esriFallbackPending) return;
|
||||
this._esriFallbackPending = true;
|
||||
const message = 'Esri Satellite tile requests failed; using OSM';
|
||||
this._onError?.(message, this.getStack('esri-imagery'));
|
||||
void this.setStack('osm', { silent: true }).then((state) => {
|
||||
if (state?.activeId === 'osm') {
|
||||
this._lastError = message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
}).finally(() => {
|
||||
this._esriFallbackPending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
if (this._removeImageryErrorListener) {
|
||||
this._removeImageryErrorListener();
|
||||
this._removeImageryErrorListener = null;
|
||||
}
|
||||
if (!this._imageryLayer) return;
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, false);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
|
||||
try {
|
||||
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
Cesium.GoogleMaps.defaultApiKey = undefined;
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user