Merge upstream/main into pr/mapillary-layer

Brings in #744 (share-link token ledger). package.json: keep the branch's
sorted scripts and add layer-token:next and layer-token:check.

Street Level's token moves from the provisional `3` to `0`: following
CONTRIBUTING, the provisional entry was removed and
`npm run layer-token:next -- street-level` assigned `0` (free digits in
order). It is added to the registry and to
src/data/layerStateTokenReservations.json, and
`npm run layer-token:check -- --base-ref upstream/main` passes (28
published, 1 new). No link with the provisional token was ever published.

Pinned token tests move with the ledger as CONTRIBUTING asks: 29
reservations, next free digit `3`, and the competing-PR and retired-digit
scenarios shift up one free digit (0->3, 3->4) so they still test the same
rules. The README share examples and the free-digit notes in
CONTRIBUTING and CURRENT-STATE now read `0`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-09-28 21:49:46 +01:00
commit ecb16b2fe2
16 files changed
+1477 -23

No files matched your search

+5
View File
@@ -29,6 +29,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -48,6 +49,10 @@ jobs:
- name: Check package boundaries - name: Check package boundaries
run: npm run check:boundaries run: npm run check:boundaries
- name: Check published layer-token allocations
if: github.event_name == 'pull_request'
run: npm run layer-token:check -- --base-ref origin/main
- name: Run unit tests - name: Run unit tests
run: npm test run: npm test
+13
View File
@@ -63,6 +63,19 @@
`src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and
the `renderable`-not-`enabled` choice (KnottyDyes, #705). the `renderable`-not-`enabled` choice (KnottyDyes, #705).
- Make share-link layer tokens durable allocations instead of ad hoc picks.
Existing one-character mappings are pinned permanently, while new layers use
the next free single-character digit, then two-character base-36 tokens.
Authors record each token in the permanent JSON ledger after running
`npm run layer-token:next -- <layer-id>` on a rebased branch.
`npm run layer-token:check -- --base-ref origin/main` guards published
assignments and allocation order in
pull-request CI. Existing v2 links keep their exact meaning.
Malformed enabled-layer lists now reject the whole layer payload instead of
restoring a partial list. A QA-only browser fixture exercises the future
two-character path after a fixture-only exhausted-digit scenario, without
reserving `00` in production.
- Region scopes in voice analyst queries ("in the Gulf of Mexico", "over - Region scopes in voice analyst queries ("in the Gulf of Mexico", "over
the Alps") work again in the dev server: the bundled Natural Earth and the Alps") work again in the dev server: the bundled Natural Earth and
neighborhood packs are fetched as JSON in the browser neighborhood packs are fetched as JSON in the browser
+33
View File
@@ -70,6 +70,39 @@ gate for the feature you changed.
> and a browser. Include the applicable local run in your PR's validation > and a browser. Include the applicable local run in your PR's validation
> evidence. > evidence.
## Share-link layer tokens
Every shareable layer owns an immutable token in `LAYER_STATE_REGISTRY` and
`src/data/layerStateTokenReservations.json`. Never change or reuse an existing
token: published v2 links are authored state, and the reservation remains even
if its layer is later removed.
New layers first use the remaining unreserved single-character digits (`0`
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
current published ledger, `0`, `1` and `2` are already owned, so the next
choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
JSON reservation ledger and registry entry, and keep the exact registered-layer
count assertion up to date. The token is assigned against the merge-time base, not
claimed when a PR opens, so another merged layer may require rerunning the
command after a rebase. Never repurpose a published or retired token.
Run `npm run layer-token:check -- --base-ref origin/main` after the rebase;
this checks that every published assignment remains reserved and that new
assignments consume free digits before the two-character tokens, in order.
Pull-request CI runs the same check against `main`.
The helper reads the local ledger; it does not run during a rebase or edit files.
If another PR publishes your provisional token first, keep every published
registry entry and ledger reservation from the refreshed `main`. Temporarily
remove only your still-unpublished layer's registry entry and ledger row so the
local registry is valid again, then rerun `npm run layer-token:next -- <layer-id>`.
For example, if PR A publishes `0` while PR B still provisionally uses `0`,
PR B removes only its own provisional entry, reruns the helper, and receives
`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count
and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`.
An invalid duplicate ledger can fail during module import before the helper
prints anything; never fix that by renaming or deleting PR A's published token.
## Good first contributions ## Good first contributions
The highest-leverage places to jump in: The highest-leverage places to jump in:
+1 -1
View File
@@ -340,7 +340,7 @@ Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL
The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe. The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe.
Share links carry the layer's provider switches and filter (`3.m.0` turns Mapillary off, `3.p.p` keeps only panoramas, `3.s.365` shows the last year). Adding a provider means implementing the contract in `src/layers/streetLevel/registry.js` and registering it in `src/app/layers/streetLevel.js`; the chip, credit and share bit follow. Share links carry the layer's provider switches and filter (`0.m.0` turns Mapillary off, `0.p.p` keeps only panoramas, `0.s.365` shows the last year). Adding a provider means implementing the contract in `src/layers/streetLevel/registry.js` and registering it in `src/app/layers/streetLevel.js`; the chip, credit and share bit follow.
## 🎖️ Field Missions ## 🎖️ Field Missions
+26 -4
View File
@@ -2773,7 +2773,7 @@ its criteria cannot be silently ignored.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level lives in the right context rail next to CCTV as an ordinary Street Level lives in the right context rail next to CCTV as an ordinary
collapsible panel (`#street-level-panel`, share token `3`, option owner collapsible panel (`#street-level-panel`, share token `0`, option owner
`street-level`) and starts collapsed. It is modelled on the iD editor's photo `street-level`) and starts collapsed. It is modelled on the iD editor's photo
overlay: one panel, a PROVIDERS chip per registered imagery provider, shared overlay: one panel, a PROVIDERS chip per registered imagery provider, shared
360°/flat and captured-since filters (stored as relative days so a link keeps 360°/flat and captured-since filters (stored as relative days so a link keeps
@@ -3573,11 +3573,33 @@ silently demoting every later lookup for the session.
with compact fields for enabled layers, allowlisted layer options, panel state, with compact fields for enabled layers, allowlisted layer options, panel state,
and the active preset's allowlisted shader controls. An absent layer field uses and the active preset's allowlisted shader controls. An absent layer field uses
deterministic defaults; an explicit empty field means no enabled layers. deterministic defaults; an explicit empty field means no enabled layers.
- The registry seals only after all 16 production layers register, and every - The registry seals only after every production layer registers, and every
layer has an explicit serialization disposition. Unknown enabled-layer tokens layer has an explicit serialization disposition. Unknown, empty, or repeated
reject the layer payload; unknown option tokens are ignored. Restoration enabled-layer tokens reject the whole layer payload (`l=` alone is the valid
explicit-empty form); unknown option tokens are ignored. Restoration
settles independently per layer so one failed or unavailable source cannot settles independently per layer so one failed or unavailable source cannot
block its siblings. block its siblings.
- Layer tokens are permanent public compatibility identifiers. The reservation
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
one-character assignments (all letters plus `0`, `1` and
`2` on current `main`) even if a layer is later removed. New layers allocate
the remaining unreserved single-character digits first (`3` through `9` on
current `main`), then the next unreserved two-character
base-36 token (`00` through `zz`) after rebasing onto the merge-time `main`;
the dot-delimited v2 codec accepts both widths without changing existing
links or the schema version. The contributor
check reads the complete published JSON ledger from `origin/main`, including
retired reservations, so later assignments cannot silently replace published
ones. The pre-ledger base is accepted only at its known source blob; unknown
historical shapes fail closed.
Pull-request CI runs this check against `origin/main`.
- `scripts/qa-layer-token-twochar.mjs` simulates exhaustion of the earlier
digit slots and injects a no-network `00` layer only into isolated dev-browser
responses. It proves encoding, a cold-recipient reload, restoration, explicit
OFF, and legacy-token compatibility. This is synthetic
end-to-end evidence, not production allocation: `00` remains unreserved until
the first eligible real layer receives it after the free digits through the
normal allocation process and passes its own application share/reload check.
- Stable visible options are limited to aircraft 3D mode, selected civilian and - Stable visible options are limited to aircraft 3D mode, selected civilian and
military flight IDs, Satellite catalog and selection, CCTV coverage/projection/ military flight IDs, Satellite catalog and selection, CCTV coverage/projection/
auto-hop, and Radio filter/volume. Playback and tuning, live-data health, auto-hop, and Radio filter/volume. Playback and tuning, live-data health,
+2
View File
@@ -39,6 +39,8 @@
"doctor": "node scripts/setup-doctor.mjs", "doctor": "node scripts/setup-doctor.mjs",
"format": "node scripts/format.mjs --write", "format": "node scripts/format.mjs --write",
"format:check": "node scripts/format.mjs --check", "format:check": "node scripts/format.mjs --check",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs",
"opensky:import": "./scripts/opensky-import-client.sh", "opensky:import": "./scripts/opensky-import-client.sh",
"preview": "vite preview", "preview": "vite preview",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
LAYER_STATE_TOKEN_RESERVATIONS,
parseLayerStateTokenReservations,
validateLayerStateAllocations,
validateLayerStateRegistry,
} from '../src/data/layerState.js';
// The pre-ledger main revision had exactly the 28 pinned v2 registry entries.
// Accept only that source blob as the one-time bootstrap; any other published
// source without a ledger must fail closed rather than risk losing a token.
const PRE_LEDGER_LAYER_STATE_BLOB = 'e559a4c40191512dcefb83134958a3bfd4a98a69';
// This snapshot belongs to the verified published blob above, not to the PR's
// editable legacy map. Otherwise a candidate could redefine both its ledger
// and its comparison baseline before the first ledger reaches main.
export const PRE_LEDGER_LAYER_STATE_TOKENS = Object.freeze({
'ais-live-vessels': 'a',
'alpr-cameras': 'p',
'bhote-koshi-2026': 'h',
'bhote-koshi-locator': 'z',
bikeshare: 'b',
cctv: 'c',
directions: 'n',
earthquakes: 'e',
'fire-perimeters': '2',
flights: 'f',
'local-dams': 'q',
'local-datacenters': 'd',
'local-firms': 'w',
military: 'm',
'military-awareness': 'g',
'military-installations': 'i',
radio: 'r',
'recent-imagery': '1',
'rocket-launches': 'x',
satellites: 's',
'telegeography-submarine-cables': 'u',
traffic: 't',
transit: 'j',
'weather-cyclones': 'y',
'weather-lightning': 'l',
'weather-radar': 'v',
'weather-satellite': 'o',
wind: 'k',
});
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
const SOURCE_PATH = 'src/data/layerState.js';
function git(args, cwd) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
/** Read every permanent assignment from a published Git revision. */
export function readPublishedLayerStateReservations(
baseRef,
cwd = process.cwd(),
) {
const commit = git(
['rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`],
cwd,
);
const ledgerSpec = `${commit}:${LEDGER_PATH}`;
let hasLedger = true;
try {
git(['cat-file', '-e', ledgerSpec], cwd);
} catch {
hasLedger = false;
}
if (!hasLedger) {
const sourceBlob = git(['rev-parse', `${commit}:${SOURCE_PATH}`], cwd);
if (sourceBlob !== PRE_LEDGER_LAYER_STATE_BLOB) {
throw new Error(
'Published base has no recognizable layer-state token ledger',
);
}
return PRE_LEDGER_LAYER_STATE_TOKENS;
}
let rows;
try {
rows = JSON.parse(git(['show', ledgerSpec], cwd));
} catch (error) {
throw new Error(
`Cannot read published layer-state token ledger: ${error.message}`,
);
}
return parseLayerStateTokenReservations(rows);
}
export function checkLayerStateTokens(baseRef, cwd = process.cwd()) {
const base = readPublishedLayerStateReservations(baseRef, cwd);
validateLayerStateRegistry();
validateLayerStateAllocations(base, LAYER_STATE_TOKEN_RESERVATIONS);
return {
published: Object.keys(base).length,
added:
Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length -
Object.keys(base).length,
};
}
const invokedPath = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invokedPath) {
const args = process.argv.slice(2);
const baseRef =
args[0] === '--base-ref' && args.length === 2 ? args[1] : null;
if (!baseRef || baseRef.startsWith('-')) {
console.error('Usage: npm run layer-token:check -- --base-ref origin/main');
process.exitCode = 1;
} else {
try {
const result = checkLayerStateTokens(baseRef);
console.log(
`Layer tokens valid against ${baseRef}: ${result.published} published, ` +
`${result.added} new.`,
);
} catch (error) {
console.error(`Layer token check failed: ${error.message}`);
process.exitCode = 1;
}
}
}
+5
View File
@@ -4,6 +4,11 @@
"scripts/format-scope.json", "scripts/format-scope.json",
"scripts/package-boundaries.json", "scripts/package-boundaries.json",
"scripts/format.mjs", "scripts/format.mjs",
"scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs",
"src/data/layerStateTokenReservations.json",
"src/data/layerStateTokenLedger.test.mjs",
"scripts/check-package-boundaries.mjs", "scripts/check-package-boundaries.mjs",
"scripts/precompute-cctv-heights.mjs", "scripts/precompute-cctv-heights.mjs",
"src/tooling/precomputeCctvHeights.test.mjs", "src/tooling/precomputeCctvHeights.test.mjs",
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import {
LAYER_STATE_TOKEN_RESERVATIONS,
nextLayerStateToken,
} from '../src/data/layerState.js';
const layerId = String(process.argv[2] || '').trim();
if (!/^[a-z0-9-]+$/.test(layerId)) {
console.error('Usage: npm run layer-token:next -- <layer-id>');
process.exitCode = 1;
} else if (Object.hasOwn(LAYER_STATE_TOKEN_RESERVATIONS, layerId)) {
console.error(
`${layerId} already owns token ${LAYER_STATE_TOKEN_RESERVATIONS[layerId]}`,
);
process.exitCode = 1;
} else {
console.log(`${layerId}: ${nextLayerStateToken()}`);
console.log('Order: free digits 0-9, then two-character base-36 00-zz.');
console.log('Re-run after rebasing onto the latest main before merge.');
}
+8
View File
@@ -523,6 +523,7 @@
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/militaryAwarenessEngine.js", "src/data/militaryAwarenessEngine.js",
"src/data/scenePick.js", "src/data/scenePick.js",
"src/keySetupCore.mjs", "src/keySetupCore.mjs",
@@ -765,6 +766,7 @@
"src/data/contactMatch.js", "src/data/contactMatch.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js", "src/data/modelVisualAnchor.js",
"src/data/motionModel.js", "src/data/motionModel.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
@@ -806,6 +808,7 @@
"src/data/contactMatch.js", "src/data/contactMatch.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js", "src/data/modelVisualAnchor.js",
"src/data/motionModel.js", "src/data/motionModel.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
@@ -842,6 +845,7 @@
"src/data/contactMatch.js", "src/data/contactMatch.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js", "src/data/modelVisualAnchor.js",
"src/data/motionModel.js", "src/data/motionModel.js",
"src/data/renderAltitude.js", "src/data/renderAltitude.js",
@@ -1170,6 +1174,7 @@
"modules": [ "modules": [
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js", "src/data/radioCountry.js",
"src/layers/radio/catalogModel.js", "src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js", "src/layers/radio/categories.js",
@@ -1439,6 +1444,7 @@
"src/data/issPass.js", "src/data/issPass.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/lifecycle.js", "src/data/lifecycle.js",
"src/data/localGeojson.js", "src/data/localGeojson.js",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
@@ -2132,6 +2138,7 @@
"src/data/satellitePass.js", "src/data/satellitePass.js",
"src/data/labelArbiter.js", "src/data/labelArbiter.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js", "src/data/localGeojson.js",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
@@ -2610,6 +2617,7 @@
"modules": [ "modules": [
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/navigationPolicy.js", "src/navigationPolicy.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env node
/**
* End-to-end share/reload proof for the first two-character token. The QA
* layer and an exhausted-digit reservation scenario are injected into
* dev-server responses in this isolated browser only. Neither belongs in a
* production bundle or ledger.
*
* Run against a local Vite server: node scripts/qa-layer-token-twochar.mjs
*/
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import {
LAYER_STATE_TOKEN_RESERVATIONS,
nextLayerStateToken,
parseLayerStateTokenReservations,
validateLayerStateAllocations,
} from '../src/data/layerState.js';
const base = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
if (!['127.0.0.1', 'localhost'].includes(base.hostname))
throw new Error('The QA fixture requires a loopback-only dev server');
const origin = base.origin;
const fixtureId = 'qa-twochar-fixture';
const injections = { codec: 0, catalog: 0 };
const errors = [];
const reservations = JSON.parse(
await readFile(
new URL('../src/data/layerStateTokenReservations.json', import.meta.url),
'utf8',
),
);
assert.equal(
reservations.some(([, token]) => token === '00'),
false,
'00 must remain unallocated in the production ledger for this QA fixture',
);
const productionTokens = new Set(reservations.map(([, token]) => token));
const priorDigitRows = [...'0123456789']
.filter((digit) => !productionTokens.has(digit))
.map((digit) => [`qa-prior-digit-${digit}`, digit]);
const priorState = parseLayerStateTokenReservations([
...reservations,
...priorDigitRows,
]);
assert.equal(nextLayerStateToken(priorState), '00');
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
parseLayerStateTokenReservations([
...reservations,
...priorDigitRows,
[fixtureId, '00'],
]),
),
true,
);
console.log(
'PASS: isolated fixture exhausts prior digits before allocating 00',
);
for (const source of [
'../src/data/layerState.js',
'../src/app/constructCatalog.js',
]) {
assert.equal(
(await readFile(new URL(source, import.meta.url), 'utf8')).includes(
fixtureId,
),
false,
`${fixtureId} must not exist in production source`,
);
}
function replaceOnce(source, from, to, label) {
const first = source.indexOf(from);
if (first < 0 || source.indexOf(from, first + from.length) >= 0)
throw new Error(`QA fixture anchor changed: ${label}`);
return source.replace(from, to);
}
function injectCodec(source) {
const fixtureRows = JSON.stringify([...priorDigitRows, [fixtureId, '00']]);
let result = replaceOnce(
source,
'parseLayerStateTokenReservations(reservationRows)',
`parseLayerStateTokenReservations([...reservationRows, ...${fixtureRows}])`,
'reservation',
);
result = replaceOnce(
result,
']);\n\nexport const REGISTERED_LAYER_IDS',
` Object.freeze({ id: '${fixtureId}', token: '00', disposition: 'enabled-only' }),\n]);\n\nexport const REGISTERED_LAYER_IDS`,
'registry',
);
injections.codec += 1;
return result;
}
function injectCatalog(source) {
const result = replaceOnce(
source,
' createBhoteKoshiEventLayer(),',
` {
id: '${fixtureId}',
name: 'Synthetic two-character token',
icon: '◆',
source: 'QA-only fixture',
async init() { return true; },
async enable() { return true; },
async update() { return true; },
async disable() { return true; },
async destroy() { return true; },
getStats() { return { count: 0 }; },
},
createBhoteKoshiEventLayer(),`,
'catalog',
);
injections.catalog += 1;
return result;
}
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
async function newPage() {
const context = await browser.createBrowserContext();
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1280, height: 800 });
await page.setCacheEnabled(false);
await page.evaluateOnNewDocument(() => {
window.__qaRestoreEvents = [];
window.addEventListener('gev:initial-share-restore-settled', (event) => {
window.__qaRestoreEvents.push(event.detail);
});
});
await page.setRequestInterception(true);
page.on('request', async (request) => {
try {
const url = new URL(request.url());
if (url.origin !== origin) return await request.abort();
const transform =
url.pathname === '/src/data/layerState.js'
? injectCodec
: url.pathname === '/src/app/constructCatalog.js'
? injectCatalog
: null;
if (!transform) return await request.continue();
const response = await fetch(request.url());
assert.equal(response.status, 200, `dev module ${url.pathname}`);
await request.respond({
status: 200,
contentType: 'application/javascript',
body: transform(await response.text()),
});
} catch (error) {
errors.push(String(error?.stack || error));
await request.abort().catch(() => {});
}
});
return { context, page };
}
async function load(page, url) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
(id) => window.__godsEyeView?.dataManager?.layers?.has(id),
{ timeout: 60_000 },
fixtureId,
);
}
async function loadedState(page) {
return page.evaluate((id) => {
const manager = window.__godsEyeView.dataManager;
return {
enabled: manager.isEnabled(id),
flights: manager.isEnabled('flights'),
localStorage: localStorage.getItem('gev:layer-state:v2'),
restoreEvents: window.__qaRestoreEvents,
hash: location.hash,
};
}, fixtureId);
}
async function waitForRestore(page) {
await page.waitForFunction(() => window.__qaRestoreEvents.length > 0, {
timeout: 60_000,
});
return loadedState(page);
}
function withLayers(link, layers) {
const url = new URL(link);
const params = new URLSearchParams(url.hash.slice(1));
params.set('l', layers);
url.hash = params.toString();
return url.href;
}
const contexts = [];
try {
const sender = await newPage();
contexts.push(sender.context);
await load(sender.page, `${origin}/?welcome=0`);
const toggled = await sender.page.evaluate(
(id) =>
window.__godsEyeView.dataManager.setEnabled(id, true, { origin: 'user' }),
fixtureId,
);
assert.equal(toggled, true, 'synthetic layer enables through manager');
await sender.page.waitForFunction(
() => new URLSearchParams(location.hash.slice(1)).get('l') === '00',
{ timeout: 15_000 },
);
const sharedUrl = sender.page.url();
const authored = new URLSearchParams(new URL(sharedUrl).hash.slice(1));
assert.equal(authored.get('v'), '2');
assert.equal(authored.get('l'), '00');
assert.ok(authored.has('lat') && authored.has('lon'));
console.log('PASS: real user-origin enable encoded 00 in a full share URL');
const recipient = await newPage();
contexts.push(recipient.context);
await load(recipient.page, sharedUrl);
const restored = await waitForRestore(recipient.page);
assert.equal(restored.enabled, true);
assert.equal(restored.localStorage, null);
assert.equal(restored.restoreEvents.at(-1)?.status, 'settled');
assert.ok(
restored.restoreEvents
.at(-1)
?.layers?.some(
(layer) =>
layer.layerId === fixtureId &&
layer.targetEnabled === true &&
layer.succeeded === true,
),
'share coordinator restored the synthetic layer',
);
console.log(
'PASS: cold recipient reload restored the 00 layer without writing local preferences',
);
await recipient.page.reload({
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await recipient.page.waitForFunction(
(id) => window.__godsEyeView?.dataManager?.layers?.has(id),
{ timeout: 60_000 },
fixtureId,
);
const reloaded = await waitForRestore(recipient.page);
assert.equal(reloaded.enabled, true);
assert.equal(reloaded.localStorage, null);
assert.equal(new URLSearchParams(reloaded.hash.slice(1)).get('l'), '00');
console.log('PASS: a full recipient reload restores the same 00 layer state');
const disabled = await recipient.page.evaluate(
(id) =>
window.__godsEyeView.dataManager.setEnabled(id, false, {
origin: 'user',
}),
fixtureId,
);
assert.equal(disabled, true);
await recipient.page.waitForFunction(
() => new URLSearchParams(location.hash.slice(1)).get('l') === '',
{ timeout: 15_000 },
);
assert.equal((await loadedState(recipient.page)).enabled, false);
console.log('PASS: explicit OFF removes 00 from the authored share URL');
const legacy = await newPage();
contexts.push(legacy.context);
await load(legacy.page, withLayers(sharedUrl, 'f'));
const legacyState = await waitForRestore(legacy.page);
assert.equal(legacyState.flights, true);
assert.equal(legacyState.enabled, false);
assert.equal(legacyState.restoreEvents.at(-1)?.status, 'settled');
console.log('PASS: a legacy one-character token still restores');
const mixed = await newPage();
contexts.push(mixed.context);
await load(mixed.page, withLayers(sharedUrl, 'f.00'));
const mixedState = await waitForRestore(mixed.page);
assert.equal(mixedState.flights, true);
assert.equal(mixedState.enabled, true);
console.log('PASS: mixed legacy and two-character tokens restore together');
const malformed = await newPage();
contexts.push(malformed.context);
await load(malformed.page, withLayers(sharedUrl, 'f..00'));
const malformedState = await waitForRestore(malformed.page);
assert.equal(malformedState.flights, false);
assert.equal(malformedState.enabled, false);
assert.equal(malformedState.localStorage, null);
console.log(
'PASS: malformed layer list is rejected without partial restoration',
);
const repeated = await newPage();
contexts.push(repeated.context);
const repeatedUrl = new URL(withLayers(sharedUrl, 'f'));
const repeatedParams = new URLSearchParams(repeatedUrl.hash.slice(1));
repeatedParams.append('l', '00');
repeatedUrl.hash = repeatedParams.toString();
await load(repeated.page, repeatedUrl.href);
const repeatedState = await waitForRestore(repeated.page);
assert.equal(repeatedState.flights, false);
assert.equal(repeatedState.enabled, false);
assert.equal(repeatedState.localStorage, null);
console.log('PASS: repeated layer fields reject the entire share payload');
assert.ok(injections.codec >= contexts.length);
assert.ok(injections.catalog >= contexts.length);
assert.deepEqual(errors, []);
console.log(
'PASS: QA-only codec and catalog injections reached every isolated session',
);
console.log(
'Synthetic end-to-end evidence only; production 00 allocation remains pending.',
);
console.log('RESULT: 10 passed, 0 failed, 0 skipped');
} finally {
await Promise.all(contexts.map((context) => context.close().catch(() => {})));
await browser.close();
}
+207 -15
View File
@@ -1,3 +1,5 @@
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([ const VALID_DISPOSITIONS = new Set([
'enabled-only', 'enabled-only',
'enabled+options', 'enabled+options',
@@ -5,6 +7,10 @@ const VALID_DISPOSITIONS = new Set([
]); ]);
export const LAYER_STATE_VERSION = 2; export const LAYER_STATE_VERSION = 2;
export const LAYER_STATE_TOKEN_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyz';
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
/** Re-check cadence while a shared subject waits for its feed row to arrive. */ /** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000; const PENDING_TRACKING_POLL_MS = 1_000;
/** /**
@@ -17,12 +23,13 @@ const PENDING_TRACKING_POLL_MS = 1_000;
*/ */
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
/** /**
* Ceilings for the untrusted v2 layer fields. Both are far above any legitimate * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* payload (16 one-character tokens; a dozen short option assignments), so a * complete reserved one-character space plus every two-character base-36
* value past them is malformed or hostile. Reject the WHOLE payload, matching * allocation, while the option ceiling covers a dozen short assignments.
* the unknown-token rule — never salvage a prefix. * Reject the WHOLE payload, matching the unknown-token rule — never salvage a
* prefix.
*/ */
const MAX_ENABLED_LAYERS_CHARS = 64; const MAX_ENABLED_LAYERS_CHARS = 4_096;
const MAX_LAYER_OPTIONS_CHARS = 512; const MAX_LAYER_OPTIONS_CHARS = 512;
export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2'; export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2';
export const LAYER_RESTORE_ORIGINS = Object.freeze({ export const LAYER_RESTORE_ORIGINS = Object.freeze({
@@ -457,6 +464,94 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
}), }),
}); });
/**
* The original single-character assignments are a closed compatibility set.
* Keep their exact mapping pinned by the independent snapshot in the tests.
*/
export const LEGACY_LAYER_STATE_TOKENS = Object.freeze({
'ais-live-vessels': 'a',
'alpr-cameras': 'p',
'bhote-koshi-2026': 'h',
'bhote-koshi-locator': 'z',
bikeshare: 'b',
cctv: 'c',
directions: 'n',
earthquakes: 'e',
'fire-perimeters': '2',
flights: 'f',
'local-dams': 'q',
'local-datacenters': 'd',
'local-firms': 'w',
military: 'm',
'military-awareness': 'g',
'military-installations': 'i',
radio: 'r',
'recent-imagery': '1',
'rocket-launches': 'x',
satellites: 's',
'telegeography-submarine-cables': 'u',
traffic: 't',
transit: 'j',
'weather-cyclones': 'y',
'weather-lightning': 'l',
'weather-radar': 'v',
'weather-satellite': 'o',
wind: 'k',
});
/**
* Permanent token ownership. Existing share links are public authored state,
* so an allocation stays here even if its layer is later removed. The JSON
* ledger is also read directly from the published Git base by the checker.
*/
export function parseLayerStateTokenReservations(rows) {
if (!Array.isArray(rows)) {
throw new Error('Layer-state token ledger must be an array');
}
const reservations = Object.create(null);
const reservedIdsByToken = new Map();
for (const row of rows) {
if (
!Array.isArray(row) ||
row.length !== 2 ||
typeof row[0] !== 'string' ||
typeof row[1] !== 'string'
) {
throw new Error('Invalid layer-state token ledger row');
}
const [id, token] = row;
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
throw new Error(`Invalid layer-state token reservation: ${id}`);
}
if (Object.hasOwn(reservations, id)) {
throw new Error(`Duplicate layer-state token reservation id: ${id}`);
}
if (reservedIdsByToken.has(token)) {
throw new Error(`Duplicate layer-state token reservation: ${token}`);
}
if (
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
(token.length === 1 &&
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
LEGACY_LAYER_STATE_TOKENS[id] !== token)
) {
throw new Error(`Legacy layer-state token is immutable: ${id}`);
}
reservations[id] = token;
reservedIdsByToken.set(token, id);
}
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
if (reservations[id] !== token) {
throw new Error(`Missing or changed legacy layer-state token: ${id}`);
}
}
return Object.freeze(reservations);
}
export const LAYER_STATE_TOKEN_RESERVATIONS =
parseLayerStateTokenReservations(reservationRows);
/** /**
* Canonical serialization registry. Its order, not runtime registration order, * Canonical serialization registry. Its order, not runtime registration order,
* owns stable URL ordering. * owns stable URL ordering.
@@ -550,7 +645,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}), }),
Object.freeze({ Object.freeze({
id: 'street-level', id: 'street-level',
token: '3', token: '0',
disposition: 'enabled+options', disposition: 'enabled+options',
optionOwner: 'street-level', optionOwner: 'street-level',
}), }),
@@ -596,6 +691,60 @@ export const REGISTERED_LAYER_IDS = Object.freeze(
LAYER_STATE_REGISTRY.map((entry) => entry.id), LAYER_STATE_REGISTRY.map((entry) => entry.id),
); );
/** Consume unreserved digits before the two-character base-36 namespace. */
export function nextLayerStateToken(
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
const occupied = new Set(Object.values(reservations || {}));
for (const digit of '0123456789') {
if (!occupied.has(digit)) return digit;
}
for (const first of LAYER_STATE_TOKEN_ALPHABET) {
for (const second of LAYER_STATE_TOKEN_ALPHABET) {
const candidate = `${first}${second}`;
if (!occupied.has(candidate)) return candidate;
}
}
throw new Error('Layer-state token namespace exhausted');
}
function allocationRank(token) {
if (NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token)) return Number(token);
if (typeof token !== 'string' || token.length !== 2) return Infinity;
const first = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]);
const second = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]);
return first < 0 || second < 0 ? Infinity : 10 + first * 36 + second;
}
/** Check a proposed ledger against the published merge-time base. */
export function validateLayerStateAllocations(
baseReservations,
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
if (!baseReservations || typeof baseReservations !== 'object') {
throw new Error('Base layer-state token reservations are required');
}
for (const [id, token] of Object.entries(baseReservations)) {
if (reservations[id] !== token) {
throw new Error(`Published layer-state token changed or removed: ${id}`);
}
}
const newlyReserved = Object.entries(reservations)
.filter(([id]) => !Object.hasOwn(baseReservations, id))
.sort((left, right) => allocationRank(left[1]) - allocationRank(right[1]));
const occupied = { ...baseReservations };
for (const [id, token] of newlyReserved) {
const expected = nextLayerStateToken(occupied);
if (token !== expected) {
throw new Error(
`Layer-state token for ${id} must be the next free token ${expected}`,
);
}
occupied[id] = token;
}
return true;
}
const REGISTRY_BY_ID = new Map( const REGISTRY_BY_ID = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]), LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
); );
@@ -636,10 +785,39 @@ export function isExplicitLayerStateOrigin(origin) {
} }
/** Validate the static registry itself before it is used to seal a manager. */ /** Validate the static registry itself before it is used to seal a manager. */
export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) { export function validateLayerStateRegistry(
registry = LAYER_STATE_REGISTRY,
reservations = LAYER_STATE_TOKEN_RESERVATIONS,
) {
if (!Array.isArray(registry) || registry.length === 0) { if (!Array.isArray(registry) || registry.length === 0) {
throw new Error('Layer-state registry must be a non-empty array'); throw new Error('Layer-state registry must be a non-empty array');
} }
if (
!reservations ||
typeof reservations !== 'object' ||
Array.isArray(reservations)
) {
throw new Error('Layer-state token reservations must be an object');
}
const reservedIdsByToken = new Map();
for (const [id, token] of Object.entries(reservations)) {
if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) {
throw new Error(`Invalid layer-state token reservation: ${id}`);
}
if (
(Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) &&
token !== LEGACY_LAYER_STATE_TOKENS[id]) ||
(token.length === 1 &&
!NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) &&
LEGACY_LAYER_STATE_TOKENS[id] !== token)
) {
throw new Error(`Legacy layer-state token is immutable: ${id}`);
}
if (reservedIdsByToken.has(token)) {
throw new Error(`Duplicate layer-state token reservation: ${token}`);
}
reservedIdsByToken.set(token, id);
}
const ids = new Set(); const ids = new Set();
const tokens = new Set(); const tokens = new Set();
for (const entry of registry) { for (const entry of registry) {
@@ -650,8 +828,14 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
if (ids.has(entry.id)) if (ids.has(entry.id))
throw new Error(`Duplicate layer-state id: ${entry.id}`); throw new Error(`Duplicate layer-state id: ${entry.id}`);
ids.add(entry.id); ids.add(entry.id);
if (!/^[a-z0-9]$/.test(entry.token || '')) if (!LAYER_STATE_TOKEN_PATTERN.test(entry.token || ''))
throw new Error(`Invalid layer-state token: ${entry.id}`); throw new Error(`Invalid layer-state token: ${entry.id}`);
if (reservations[entry.id] !== entry.token) {
throw new Error(`Unreserved layer-state token: ${entry.id}`);
}
if (reservedIdsByToken.get(entry.token) !== entry.id) {
throw new Error(`Layer-state token reservation mismatch: ${entry.token}`);
}
if (tokens.has(entry.token)) if (tokens.has(entry.token))
throw new Error(`Duplicate layer-state token: ${entry.token}`); throw new Error(`Duplicate layer-state token: ${entry.token}`);
tokens.add(entry.token); tokens.add(entry.token);
@@ -773,18 +957,26 @@ export function encodeLayerStateParams(params, state) {
/** Decode v2 fields. Null means that the layer payload is absent. */ /** Decode v2 fields. Null means that the layer payload is absent. */
export function decodeLayerStateParams(params) { export function decodeLayerStateParams(params) {
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l')) const layerFields = params.getAll('l');
if (
params.get('v') !== String(LAYER_STATE_VERSION) ||
layerFields.length !== 1
)
return null; return null;
const rawLayers = String(params.get('l') || ''); const rawLayers = layerFields[0];
const rawOptionsField = String(params.get('lo') || ''); const rawOptionsField = String(params.get('lo') || '');
// Fail closed on an oversized payload rather than decoding a truncated one. // Fail closed on an oversized payload rather than decoding a truncated one.
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null; if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null; if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
const layerTokens = rawLayers.split('.').filter(Boolean); const layerTokens = rawLayers ? rawLayers.split('.') : [];
// `l=` is the one valid explicit-empty representation. Any non-empty token // `l=` is the one valid explicit-empty representation. Reject repeated
// set containing an unknown member rejects the complete layer payload so a // fields, empty members, duplicate members, or unknown members; silently
// typo or future token cannot silently become an authoritative empty set. // removing one would turn a malformed share into a different state.
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null; if (
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
new Set(layerTokens).size !== layerTokens.length
)
return null;
const enabledLayerIds = layerTokens.map( const enabledLayerIds = layerTokens.map(
(token) => REGISTRY_BY_TOKEN.get(token).id, (token) => REGISTRY_BY_TOKEN.get(token).id,
); );
+220 -3
View File
@@ -6,17 +6,22 @@ import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js'; import { DataLayerManager } from './manager.js';
import { import {
LEGACY_LAYER_STATE_TOKENS,
LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY,
LAYER_STATE_STORAGE_KEY, LAYER_STATE_STORAGE_KEY,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
LayerStateCoordinator, LayerStateCoordinator,
REGISTERED_LAYER_IDS, REGISTERED_LAYER_IDS,
SHARE_TRACKING_RESTORE_POLICIES, SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState, createDefaultLayerState,
decodeLayerStateParams, decodeLayerStateParams,
encodeLayerStateParams, encodeLayerStateParams,
nextLayerStateToken,
normalizeLayerState, normalizeLayerState,
parseStoredLayerState, parseStoredLayerState,
serializeStoredLayerState, serializeStoredLayerState,
validateLayerStateAllocations,
validateLayerStateRegistry, validateLayerStateRegistry,
} from './layerState.js'; } from './layerState.js';
import radioLayer from './radio.js'; import radioLayer from './radio.js';
@@ -198,6 +203,144 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
'ais-live-vessels': 'a',
'alpr-cameras': 'p',
'bhote-koshi-2026': 'h',
'bhote-koshi-locator': 'z',
bikeshare: 'b',
cctv: 'c',
directions: 'n',
earthquakes: 'e',
'fire-perimeters': '2',
flights: 'f',
'local-dams': 'q',
'local-datacenters': 'd',
'local-firms': 'w',
military: 'm',
'military-awareness': 'g',
'military-installations': 'i',
radio: 'r',
'recent-imagery': '1',
'rocket-launches': 'x',
satellites: 's',
'telegeography-submarine-cables': 'u',
traffic: 't',
transit: 'j',
'weather-cyclones': 'y',
'weather-lightning': 'l',
'weather-radar': 'v',
'weather-satellite': 'o',
wind: 'k',
});
for (const { id, token } of LAYER_STATE_REGISTRY) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '3');
assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
'5',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }),
'02',
);
assert.equal(
nextLayerStateToken({
...digitsExhausted,
...Object.fromEntries(
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => [
`pair-0${second}`,
`0${second}`,
]),
),
}),
'10',
);
assert.throws(
() =>
nextLayerStateToken(
Object.fromEntries(
[
...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
),
].map((token, index) => [`occupied-${index}`, token]),
),
),
/namespace exhausted/,
);
assert.equal(
validateLayerStateRegistry(
[{ id: 'future-layer', token: '0', disposition: 'enabled-only' }],
{ 'future-layer': '0' },
),
true,
);
assert.equal(
validateLayerStateRegistry(
[{ id: 'future-layer', token: '01', disposition: 'enabled-only' }],
{ retired: '00', 'future-layer': '01' },
),
true,
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
),
true,
);
assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
assert.equal(
validateLayerStateAllocations(
beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
),
true,
);
assert.equal(
validateLayerStateAllocations(
digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' },
),
true,
);
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
),
/next free token 4/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
/changed or removed/,
);
assert.throws(
() => validateLayerStateAllocations({ retired: '00' }, { newcomer: '00' }),
/changed or removed/,
);
assert.throws( assert.throws(
() => () =>
validateLayerStateRegistry([ validateLayerStateRegistry([
@@ -206,6 +349,30 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
]), ]),
/Duplicate layer-state id/, /Duplicate layer-state id/,
); );
assert.throws(
() =>
validateLayerStateRegistry(
[{ id: 'future-layer', token: '000', disposition: 'enabled-only' }],
{ 'future-layer': '000' },
),
/Invalid layer-state token reservation/,
);
assert.throws(
() =>
validateLayerStateRegistry(
[{ id: 'future-layer', token: 'a', disposition: 'enabled-only' }],
{ 'future-layer': 'a' },
),
/Legacy layer-state token is immutable/,
);
assert.throws(
() =>
validateLayerStateRegistry(
[{ id: 'flights', token: '0', disposition: 'enabled-only' }],
{ flights: '0' },
),
/Legacy layer-state token is immutable/,
);
const manager = new DataLayerManager({}); const manager = new DataLayerManager({});
manager.register(fakeLayer('earthquakes')); manager.register(fakeLayer('earthquakes'));
@@ -289,6 +456,25 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
); );
}); });
test('all production layers and options round-trip through a v2 share URL', () => {
const allEnabled = normalizeLayerState({
enabledLayerIds: REGISTERED_LAYER_IDS,
options: {
cctv: { coverageMode: 'viewshed', showProjection: false, autoHop: true },
flights: { models3d: true, models3dMode: 'all' },
radio: { filter: 'news', volume: 0.37 },
},
});
const shareUrl = new URL('https://example.invalid/');
shareUrl.hash = encode(allEnabled);
const params = new URLSearchParams(shareUrl.hash.slice(1));
const restored = decodeLayerStateParams(params);
assert.equal(params.get('l')?.split('.').length, REGISTERED_LAYER_IDS.length);
assert.deepEqual(restored?.enabledLayerIds, REGISTERED_LAYER_IDS);
assert.deepEqual(restored?.options, allEnabled.options);
});
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => { test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
assert.equal( assert.equal(
decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')),
@@ -300,6 +486,27 @@ test('unknown enabled-layer tokens reject the payload instead of becoming an emp
); );
}); });
test('malformed enabled-layer lists reject the entire payload', () => {
for (const value of ['.c', 'c.', 'c..e', 'c.c', '00', 'c.00']) {
assert.equal(
decodeLayerStateParams(new URLSearchParams(`v=2&l=${value}`)),
null,
`l=${value}`,
);
}
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams('v=2&l=c.e')).enabledLayerIds,
['cctv', 'earthquakes'],
);
for (const fields of ['l=f&l=f', 'l=f&l=unknown', 'l=&l=f']) {
assert.equal(
decodeLayerStateParams(new URLSearchParams(`v=2&${fields}`)),
null,
fields,
);
}
});
test('Nepal event and locator have distinct enabled-only share tokens', () => { test('Nepal event and locator have distinct enabled-only share tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z')); const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
assert.deepEqual(decoded.enabledLayerIds, [ assert.deepEqual(decoded.enabledLayerIds, [
@@ -2421,7 +2628,17 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, ['fire-perimeters', 'recent-imagery', 'wind']); assert.deepEqual(decoded.enabledLayerIds, [
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2'); 'fire-perimeters',
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded); 'recent-imagery',
'wind',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token,
'2',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
}); });
+419
View File
@@ -0,0 +1,419 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import {
mkdtempSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import test from 'node:test';
import {
LEGACY_LAYER_STATE_TOKENS,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
nextLayerStateToken,
parseLayerStateTokenReservations,
validateLayerStateAllocations,
} from './layerState.js';
import {
PRE_LEDGER_LAYER_STATE_TOKENS,
checkLayerStateTokens,
readPublishedLayerStateReservations,
} from '../../scripts/check-layer-state-tokens.mjs';
const LEDGER_PATH = 'src/data/layerStateTokenReservations.json';
const SOURCE_PATH = 'src/data/layerState.js';
const CODEC_SOURCE_PATH = fileURLToPath(
new URL('./layerState.js', import.meta.url),
);
const CHECKER_PATH = fileURLToPath(
new URL('../../scripts/check-layer-state-tokens.mjs', import.meta.url),
);
const NEXT_PATH = fileURLToPath(
new URL('../../scripts/next-layer-state-token.mjs', import.meta.url),
);
const reservationRows = JSON.parse(
readFileSync(
new URL('./layerStateTokenReservations.json', import.meta.url),
'utf8',
),
);
test('pre-ledger published ownership is independent of the candidate mapping', () => {
assert.notStrictEqual(
PRE_LEDGER_LAYER_STATE_TOKENS,
LEGACY_LAYER_STATE_TOKENS,
);
assert.deepEqual(PRE_LEDGER_LAYER_STATE_TOKENS, LEGACY_LAYER_STATE_TOKENS);
assert.equal(Object.keys(PRE_LEDGER_LAYER_STATE_TOKENS).length, 28);
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['recent-imagery'], '1');
assert.equal(PRE_LEDGER_LAYER_STATE_TOKENS['fire-perimeters'], '2');
assert.throws(
() =>
validateLayerStateAllocations(PRE_LEDGER_LAYER_STATE_TOKENS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
flights: '00',
}),
/Published layer-state token changed or removed: flights/,
);
});
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
function withPublishedBase(
{ rows, source = 'export const changed = true;\n' },
check,
) {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-ledger-'));
try {
git(cwd, 'init', '-q');
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
writeFileSync(path.join(cwd, SOURCE_PATH), source);
if (rows !== undefined) {
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
}
git(cwd, 'add', 'src/data');
git(
cwd,
'-c',
'user.name=Layer Token Test',
'-c',
'[email protected]',
'commit',
'-q',
'-m',
'published base',
);
check(cwd);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}
function writeCodecFixture(cwd, rows, entries) {
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
const additions = entries
.map(
({ id, token }) =>
` Object.freeze({ id: '${id}', token: '${token}', disposition: 'enabled-only' }),`,
)
.join('\n');
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
writeFileSync(path.join(cwd, 'package.json'), '{"type":"module"}');
writeFileSync(
path.join(cwd, SOURCE_PATH),
source.replace(registryEnd, `${additions}\n${registryEnd}`),
);
writeFileSync(path.join(cwd, LEDGER_PATH), JSON.stringify(rows));
return path.join(cwd, SOURCE_PATH);
}
test('reservation ledger is complete, pinned, and rejects duplicate or malformed rows', () => {
assert.deepEqual(
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
/Missing or changed legacy/,
);
assert.throws(
() =>
parseLayerStateTokenReservations([
...reservationRows,
reservationRows[0],
]),
/Duplicate layer-state token reservation id/,
);
assert.throws(
() =>
parseLayerStateTokenReservations([...reservationRows, ['future', 'a']]),
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
.future,
'3',
);
assert.throws(
() =>
parseLayerStateTokenReservations([
...reservationRows.filter(([id]) => id !== 'transit'),
['future', 'j'],
]),
/Legacy layer-state token is immutable/,
);
assert.throws(
() => parseLayerStateTokenReservations([...reservationRows, ['future']]),
/Invalid layer-state token ledger row/,
);
});
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '3']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '4',
}),
true,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...published,
competing: '3',
}),
/next free token 4/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '3',
}),
/changed or removed: retired-layer/,
);
},
);
});
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
'scripts/check-layer-state-tokens.mjs',
);
writeFileSync(fixtureChecker, readFileSync(CHECKER_PATH, 'utf8'));
const fixtureNext = path.join(cwd, 'scripts/next-layer-state-token.mjs');
writeFileSync(fixtureNext, readFileSync(NEXT_PATH, 'utf8'));
const runCheck = () =>
spawnSync(
process.execPath,
[realpathSync(fixtureChecker), '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
assert.match(beforeRebase.stderr, /changed or removed: pr-a/);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
],
);
const staleRebase = runCheck();
assert.equal(staleRebase.status, 1);
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 4/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
'the helper reports a token without rewriting the ledger',
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /30 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
delete beforeNine['retired-9'];
assert.equal(
validateLayerStateAllocations(beforeNine, {
...beforeNine,
pair: '00',
lastDigit: '9',
}),
true,
);
const baseBefore0a = {
...allDigits,
...Object.fromEntries(
[...'012345678'].map((second) => [`retired-0${second}`, `0${second}`]),
),
};
assert.equal(nextLayerStateToken(baseBefore0a), '09');
assert.equal(
validateLayerStateAllocations(baseBefore0a, {
...baseBefore0a,
after: '0a',
before: '09',
}),
true,
);
const baseBefore10 = {
...allDigits,
...Object.fromEntries(
[...LAYER_STATE_TOKEN_ALPHABET]
.slice(0, -1)
.map((second) => [`retired-0${second}`, `0${second}`]),
),
};
assert.equal(nextLayerStateToken(baseBefore10), '0z');
assert.equal(
validateLayerStateAllocations(baseBefore10, {
...baseBefore10,
after: '10',
before: '0z',
}),
true,
);
});
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'3456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
id: `qa-layer-${token}`,
token,
}));
const fixtureRows = [
...reservationRows,
...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]),
];
const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers);
const codec = await import(pathToFileURL(fixtureModule));
assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal(
nextLayerStateToken(
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
),
'00',
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
codec.LAYER_STATE_TOKEN_RESERVATIONS,
),
true,
);
const expectedLayerIds = codec.REGISTERED_LAYER_IDS;
const state = codec.normalizeLayerState({
enabledLayerIds: expectedLayerIds,
});
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 69);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
withPublishedBase(
{
rows: [...reservationRows, ['retired-layer', '00']],
source:
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "00" });\n',
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 30);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '00',
}),
/changed or removed/,
);
assert.throws(
() => checkLayerStateTokens('HEAD', cwd),
/changed or removed/,
);
const cli = spawnSync(
process.execPath,
[CHECKER_PATH, '--base-ref', 'HEAD'],
{
cwd,
encoding: 'utf8',
},
);
assert.equal(cli.status, 1);
assert.match(cli.stderr, /changed or removed/);
},
);
});
test('checker fails closed on unknown or malformed published ledgers', () => {
withPublishedBase({}, (cwd) => {
assert.throws(
() => readPublishedLayerStateReservations('HEAD', cwd),
/no recognizable layer-state token ledger/,
);
});
withPublishedBase({ rows: [...reservationRows, ['future', 'a']] }, (cwd) => {
assert.throws(
() => readPublishedLayerStateReservations('HEAD', cwd),
/Duplicate layer-state token reservation/,
);
});
});
+31
View File
@@ -0,0 +1,31 @@
[
["ais-live-vessels", "a"],
["alpr-cameras", "p"],
["bhote-koshi-2026", "h"],
["bhote-koshi-locator", "z"],
["bikeshare", "b"],
["cctv", "c"],
["directions", "n"],
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
["military", "m"],
["military-awareness", "g"],
["military-installations", "i"],
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
["transit", "j"],
["weather-cyclones", "y"],
["weather-lightning", "l"],
["weather-radar", "v"],
["weather-satellite", "o"],
["wind", "k"]
]
+17
View File
@@ -78,6 +78,23 @@ test('unknown-only v2 layer tokens are invalid, while historical l fields stay i
} }
}); });
test('malformed v2 layer lists mark the whole incoming share payload invalid', () => {
for (const layers of ['.f', 'f.', 'f..c', 'f.f']) {
const parsed = makeManager(
`#v=2&lat=10&lon=20&l=${layers}`,
).parseInitialHash();
assert.equal(parsed.layerState, null, `l=${layers}`);
assert.equal(parsed.layerStateInvalid, true, `l=${layers}`);
}
for (const layers of ['l=f&l=f', 'l=f&l=unknown', 'l=&l=f']) {
const parsed = makeManager(
`#v=2&lat=10&lon=20&${layers}`,
).parseInitialHash();
assert.equal(parsed.layerState, null, layers);
assert.equal(parsed.layerStateInvalid, true, layers);
}
});
test('Nepal locator token is valid in v2 share links', () => { test('Nepal locator token is valid in v2 share links', () => {
const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash(); const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']); assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']);