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