mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix: allocate share-link layer tokens without collisions
This commit is contained in:
1 parent
ce671ce500
commit
54735a9e6f
15 files changed
+1295
-20
No files matched your search
@@ -29,6 +29,7 @@ jobs:
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -48,6 +49,10 @@ jobs:
|
||||
- name: Check package 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
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
- 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
|
||||
the Alps") work again in the dev server: the bundled Natural Earth and
|
||||
neighborhood packs are fetched as JSON in the browser
|
||||
|
||||
@@ -70,6 +70,27 @@ gate for the feature you changed.
|
||||
> and a browser. Include the applicable local run in your PR's validation
|
||||
> 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, `1` and `2` are already owned, so the next choices
|
||||
are `0`, `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`.
|
||||
|
||||
## Good first contributions
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
+25
-3
@@ -3454,11 +3454,33 @@ silently demoting every later lookup for the session.
|
||||
with compact fields for enabled layers, allowlisted layer options, panel state,
|
||||
and the active preset's allowlisted shader controls. An absent layer field uses
|
||||
deterministic defaults; an explicit empty field means no enabled layers.
|
||||
- The registry seals only after all 16 production layers register, and every
|
||||
layer has an explicit serialization disposition. Unknown enabled-layer tokens
|
||||
reject the layer payload; unknown option tokens are ignored. Restoration
|
||||
- The registry seals only after every production layer registers, and every
|
||||
layer has an explicit serialization disposition. Unknown, empty, or repeated
|
||||
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
|
||||
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 `1` and
|
||||
`2` on current `main`) even if a layer is later removed. New layers allocate
|
||||
the remaining unreserved single-character digits first (`0`, then `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
|
||||
military flight IDs, Satellite catalog and selection, CCTV coverage/projection/
|
||||
auto-hop, and Radio filter/volume. Playback and tuning, live-data health,
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"doctor": "node scripts/setup-doctor.mjs",
|
||||
"format": "node scripts/format.mjs --write",
|
||||
"format:check": "node scripts/format.mjs --check",
|
||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||
"dev": "vite",
|
||||
"dev:secure": "./scripts/dev-secure.sh",
|
||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
"scripts/format-scope.json",
|
||||
"scripts/package-boundaries.json",
|
||||
"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/precompute-cctv-heights.mjs",
|
||||
"src/tooling/precomputeCctvHeights.test.mjs",
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -507,6 +507,7 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keySetupCore.mjs",
|
||||
@@ -746,6 +747,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -787,6 +789,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -823,6 +826,7 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
@@ -1115,6 +1119,7 @@
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
@@ -1383,6 +1388,7 @@
|
||||
"src/data/issPass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/localGeojson.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
@@ -2046,6 +2052,7 @@
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/localGeojson.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
@@ -2497,6 +2504,7 @@
|
||||
"modules": [
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/loadingFeedback.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+206
-14
@@ -1,3 +1,5 @@
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
'enabled+options',
|
||||
@@ -5,6 +7,10 @@ const VALID_DISPOSITIONS = new Set([
|
||||
]);
|
||||
|
||||
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. */
|
||||
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}$/;
|
||||
/**
|
||||
* Ceilings for the untrusted v2 layer fields. Both are far above any legitimate
|
||||
* payload (16 one-character tokens; a dozen short option assignments), so a
|
||||
* value past them is malformed or hostile. Reject the WHOLE payload, matching
|
||||
* the unknown-token rule — never salvage a prefix.
|
||||
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
||||
* complete reserved one-character space plus every two-character base-36
|
||||
* allocation, while the option ceiling covers a dozen short assignments.
|
||||
* 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;
|
||||
export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2';
|
||||
export const LAYER_RESTORE_ORIGINS = Object.freeze({
|
||||
@@ -446,6 +453,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,
|
||||
* owns stable URL ordering.
|
||||
@@ -579,6 +674,60 @@ export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||
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(
|
||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
@@ -619,10 +768,39 @@ export function isExplicitLayerStateOrigin(origin) {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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 tokens = new Set();
|
||||
for (const entry of registry) {
|
||||
@@ -633,8 +811,14 @@ export function validateLayerStateRegistry(registry = LAYER_STATE_REGISTRY) {
|
||||
if (ids.has(entry.id))
|
||||
throw new Error(`Duplicate layer-state id: ${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}`);
|
||||
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))
|
||||
throw new Error(`Duplicate layer-state token: ${entry.token}`);
|
||||
tokens.add(entry.token);
|
||||
@@ -756,18 +940,26 @@ export function encodeLayerStateParams(params, state) {
|
||||
|
||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||
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;
|
||||
const rawLayers = String(params.get('l') || '');
|
||||
const rawLayers = layerFields[0];
|
||||
const rawOptionsField = String(params.get('lo') || '');
|
||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
|
||||
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
|
||||
const layerTokens = rawLayers.split('.').filter(Boolean);
|
||||
// `l=` is the one valid explicit-empty representation. Any non-empty token
|
||||
// set containing an unknown member rejects the complete layer payload so a
|
||||
// typo or future token cannot silently become an authoritative empty set.
|
||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||
const layerTokens = rawLayers ? rawLayers.split('.') : [];
|
||||
// `l=` is the one valid explicit-empty representation. Reject repeated
|
||||
// fields, empty members, duplicate members, or unknown members; silently
|
||||
// removing one would turn a malformed share into a different state.
|
||||
if (
|
||||
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
|
||||
new Set(layerTokens).size !== layerTokens.length
|
||||
)
|
||||
return null;
|
||||
const enabledLayerIds = layerTokens.map(
|
||||
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||
);
|
||||
|
||||
@@ -6,17 +6,22 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
nextLayerStateToken,
|
||||
normalizeLayerState,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.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, 28);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
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(), '0');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
|
||||
'4',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'03456789'].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: '0', next: '3' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 0/,
|
||||
);
|
||||
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: '0' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
|
||||
),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ retired: '00' }, { newcomer: '00' }),
|
||||
/changed or removed/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateRegistry([
|
||||
@@ -206,6 +349,30 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
]),
|
||||
/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({});
|
||||
manager.register(fakeLayer('earthquakes'));
|
||||
@@ -300,6 +467,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', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
@@ -2421,7 +2609,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', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, ['fire-perimeters', 'recent-imagery', 'wind']);
|
||||
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2');
|
||||
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded);
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'fire-perimeters',
|
||||
'recent-imagery',
|
||||
'wind',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token,
|
||||
'2',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } 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 CHECKER_PATH = fileURLToPath(
|
||||
new URL('../../scripts/check-layer-state-tokens.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 });
|
||||
}
|
||||
}
|
||||
|
||||
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, 28);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{ ...LEGACY_LAYER_STATE_TOKENS },
|
||||
);
|
||||
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', '0']])
|
||||
.future,
|
||||
'0',
|
||||
);
|
||||
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', '0']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '3',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '0',
|
||||
}),
|
||||
/next free token 3/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '0',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'03456789'].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('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, 29);
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
["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"]
|
||||
]
|
||||
@@ -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', () => {
|
||||
const parsed = makeManager('#v=2&lat=10&lon=20&l=z').parseInitialHash();
|
||||
assert.deepEqual(parsed.layerState.enabledLayerIds, ['bhote-koshi-locator']);
|
||||
|
||||
Reference in new issue
Block a user