diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ea5f599a..c5b332994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f59c0c97a..01b31531f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,19 @@ `src/app/atmosphereCompat.test.mjs` pins the probe, the platform matrix and 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 -- ` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c65efac10..cdfbb6547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,39 @@ 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 -- `, 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 -- `. +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 The highest-leverage places to jump in: diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index adf2e7995..bd7420f53 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3509,11 +3509,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, diff --git a/package.json b/package.json index 3e80dc55e..e48881c6d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-layer-state-tokens.mjs b/scripts/check-layer-state-tokens.mjs new file mode 100644 index 000000000..102c3138c --- /dev/null +++ b/scripts/check-layer-state-tokens.mjs @@ -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; + } + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ca43cbbb3..37abecbe1 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/next-layer-state-token.mjs b/scripts/next-layer-state-token.mjs new file mode 100644 index 000000000..2043d966d --- /dev/null +++ b/scripts/next-layer-state-token.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 -- '); + 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.'); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d12fc42ea..77a77141b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -509,6 +509,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", @@ -748,6 +749,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", @@ -789,6 +791,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", @@ -825,6 +828,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", @@ -1118,6 +1122,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", @@ -1387,6 +1392,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", @@ -2051,6 +2057,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", @@ -2503,6 +2510,7 @@ "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", "src/ui/shareRestoration.js", diff --git a/scripts/qa-layer-token-twochar.mjs b/scripts/qa-layer-token-twochar.mjs new file mode 100644 index 000000000..4a9c53ac1 --- /dev/null +++ b/scripts/qa-layer-token-twochar.mjs @@ -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(); +} diff --git a/src/data/layerState.js b/src/data/layerState.js index e559a4c40..ab848c998 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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, ); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index fb6d04a5d..1b504a439 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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')); @@ -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', () => { assert.equal( 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', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z')); 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', () => { 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, + ); }); diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs new file mode 100644 index 000000000..7b0a9263c --- /dev/null +++ b/src/data/layerStateTokenLedger.test.mjs @@ -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', + 'user.email=layer-token-test@example.invalid', + '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, 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('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (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', '0']], + [{ id: 'pr-b', token: '0' }], + ); + const beforeRebase = runCheck(); + assert.equal(beforeRebase.status, 1); + assert.match(beforeRebase.stderr, /changed or removed: pr-a/); + + writeCodecFixture( + cwd, + [...reservationRows, ['pr-a', '0'], ['pr-b', '0']], + [ + { id: 'pr-a', token: '0' }, + { id: 'pr-b', token: '0' }, + ], + ); + 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), '3'); + const validBaselineRows = [...reservationRows, ['pr-a', '0']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]); + 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: 3/); + 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', '0'], ['pr-b', '3']], + [ + { id: 'pr-a', token: '0' }, + { id: 'pr-b', token: '3' }, + ], + ); + const corrected = runCheck(); + assert.equal(corrected.status, 0, corrected.stderr); + assert.match(corrected.stdout, /29 published, 1 new/); + }); +}); + +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('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 = [...'03456789'].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, 67); + 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, 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/, + ); + }); +}); diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json new file mode 100644 index 000000000..600358c7c --- /dev/null +++ b/src/data/layerStateTokenReservations.json @@ -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"] +] diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 554b23265..cc2672785 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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']);