Use a neutral compact switch codec and clarify provider size limits

This commit is contained in:
Sameh Khamis committed 2026-10-09 18:47:04 -07:00
1 parent fbe118aab9
commit 3ba8c6d61b
11 files changed
+127 -98

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js';
import { normalizeIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -418,9 +418,9 @@ const OPTION_GROUPS = Object.freeze({
key: 'providerSwitches',
token: 'r',
defaultValue: '',
normalize: normalizeProviderSwitches,
encode: normalizeProviderSwitches,
decode: normalizeProviderSwitches,
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
+2 -7
View File
@@ -98,16 +98,11 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(
cwd,
'src/layers/streetLevel/providerSwitches.js',
);
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(
new URL('../layers/streetLevel/providerSwitches.js', import.meta.url),
),
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
+6 -6
View File
@@ -1,8 +1,5 @@
import { normalizeFilter } from './filter.js';
import {
encodeProviderSwitches,
decodeProviderSwitches,
} from './providerSwitches.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Provider params plus the filter. Custom switches share a stable ID-based
@@ -13,7 +10,9 @@ export function encodeParams({ providers, filter }) {
const params = {};
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeProviderSwitches(entries);
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
@@ -30,7 +29,8 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
const switches = decodeProviderSwitches(source.providerSwitches);
const switches = decodeIdSwitches(source.providerSwitches);
switches.delete('mapillary');
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
+1 -1
View File
@@ -14,7 +14,7 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax=0',
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
@@ -1,45 +0,0 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)=([01])$/;
/** Canonical, bounded provider switches; Mapillary retains its legacy token. */
export function normalizeProviderSwitches(value) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split(',')) {
const match = ENTRY.exec(field);
if (!match || match[1] === 'mapillary' || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}=${on ? '1' : '0'}`)
.join(',');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeProviderSwitches(providers) {
const value = [...providers]
.filter(([id]) => id !== 'mapillary')
.map(([id, on]) => `${id}=${on === true ? '1' : '0'}`)
.join(',');
const normalized = normalizeProviderSwitches(value);
if (normalized === null)
throw new TypeError('Invalid or oversized Street Level provider switches');
return normalized;
}
/** A Map keeps provider IDs separate from object prototype properties. */
export function decodeProviderSwitches(value) {
const normalized = normalizeProviderSwitches(value);
return new Map(
normalized
? normalized.split(',').map((entry) => {
const [id, on] = entry.split('=');
return [id, on === '1'];
})
: [],
);
}
@@ -2,9 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeProviderSwitches,
encodeProviderSwitches,
} from './providerSwitches.js';
normalizeIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
@@ -24,7 +25,7 @@ test('custom switches survive sharing and storage without depending on provider
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example=1,panoramax=0');
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
@@ -36,7 +37,11 @@ test('custom switches survive sharing and storage without depending on provider
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example=1,panoramax=0/);
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
assert.ok(
params.toString().includes('0.r.example-1*panoramax-0'),
'custom switches need no percent escaping',
);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
@@ -91,31 +96,44 @@ test('legacy links retain Mapillary semantics and leave custom defaults alone',
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example=0,example=1',
'mapillary=0',
'__proto__=1',
'a=2',
'a=1,',
'a.b=1',
'a_b=0',
`${'a'.repeat(255)}=1`,
'example-0*example-1',
'mapillary-0',
'__proto__-1',
'a-2',
'a-1*',
'a.b-1',
'a_b-0',
`${'a'.repeat(255)}-1`,
null,
{},
]) {
assert.equal(normalizeProviderSwitches(value), null);
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0');
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeProviderSwitches([['a'.repeat(255), true]]),
/oversized/,
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/oversized/,
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary=0', mapillary: true },
{ providerSwitches: 'mapillary-0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
const switches = [
['provider-0', false],
['provider-1', true],
['a-b-c', true],
];
const encoded = encodeIdSwitches(switches);
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
const decoded = decodeIdSwitches(encoded);
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
});
+13 -2
View File
@@ -1,4 +1,4 @@
import { encodeProviderSwitches } from './providerSwitches.js';
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
@@ -128,7 +128,18 @@ export function validateProviders(providers) {
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
encodeProviderSwitches(providers.map(({ id }) => [id, true]));
try {
encodeIdSwitches(
providers
.filter(({ id }) => id !== 'mapillary')
.map(({ id }) => [id, true]),
);
} catch (error) {
if (!(error instanceof RangeError)) throw error;
throw new RangeError(
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
);
}
return Object.freeze([...providers]);
}