mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +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
+207
-15
@@ -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({
|
||||
@@ -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,
|
||||
* owns stable URL ordering.
|
||||
@@ -550,7 +645,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
token: '3',
|
||||
token: '0',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'street-level',
|
||||
}),
|
||||
@@ -596,6 +691,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]),
|
||||
);
|
||||
@@ -636,10 +785,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) {
|
||||
@@ -650,8 +828,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);
|
||||
@@ -773,18 +957,26 @@ export function encodeLayerStateParams(params, state) {
|
||||
|
||||
/** Decode v2 fields. Null means that the layer payload is absent. */
|
||||
export function decodeLayerStateParams(params) {
|
||||
if (params.get('v') !== String(LAYER_STATE_VERSION) || !params.has('l'))
|
||||
const layerFields = params.getAll('l');
|
||||
if (
|
||||
params.get('v') !== String(LAYER_STATE_VERSION) ||
|
||||
layerFields.length !== 1
|
||||
)
|
||||
return null;
|
||||
const rawLayers = String(params.get('l') || '');
|
||||
const rawLayers = layerFields[0];
|
||||
const rawOptionsField = String(params.get('lo') || '');
|
||||
// Fail closed on an oversized payload rather than decoding a truncated one.
|
||||
if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null;
|
||||
if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null;
|
||||
const layerTokens = rawLayers.split('.').filter(Boolean);
|
||||
// `l=` is the one valid explicit-empty representation. Any non-empty token
|
||||
// set containing an unknown member rejects the complete layer payload so a
|
||||
// typo or future token cannot silently become an authoritative empty set.
|
||||
if (layerTokens.some((token) => !REGISTRY_BY_TOKEN.has(token))) return null;
|
||||
const layerTokens = rawLayers ? rawLayers.split('.') : [];
|
||||
// `l=` is the one valid explicit-empty representation. Reject repeated
|
||||
// fields, empty members, duplicate members, or unknown members; silently
|
||||
// removing one would turn a malformed share into a different state.
|
||||
if (
|
||||
layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) ||
|
||||
new Set(layerTokens).size !== layerTokens.length
|
||||
)
|
||||
return null;
|
||||
const enabledLayerIds = layerTokens.map(
|
||||
(token) => REGISTRY_BY_TOKEN.get(token).id,
|
||||
);
|
||||
|
||||
@@ -6,17 +6,22 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import {
|
||||
LEGACY_LAYER_STATE_TOKENS,
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_STORAGE_KEY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
LayerStateCoordinator,
|
||||
REGISTERED_LAYER_IDS,
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
nextLayerStateToken,
|
||||
normalizeLayerState,
|
||||
parseStoredLayerState,
|
||||
serializeStoredLayerState,
|
||||
validateLayerStateAllocations,
|
||||
validateLayerStateRegistry,
|
||||
} from './layerState.js';
|
||||
import radioLayer from './radio.js';
|
||||
@@ -198,6 +203,144 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
|
||||
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(), '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(
|
||||
() =>
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
]
|
||||
Reference in new issue
Block a user