From 7b1b2df76426c2a92a6936f163ef1d24d36b905f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:53:47 -0700 Subject: [PATCH 1/3] Preserve custom Street Level provider switches in shared state --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 9 +- scripts/package-boundaries.json | 11 ++ src/data/layerState.js | 11 +- src/data/layerState.test.mjs | 3 + src/data/layerStateTokenLedger.test.mjs | 11 ++ src/layers/streetLevel/params.js | 21 ++- src/layers/streetLevel/params.test.mjs | 8 +- src/layers/streetLevel/providerSwitches.js | 45 +++++++ .../streetLevel/providerSwitches.test.mjs | 121 ++++++++++++++++++ src/layers/streetLevel/registry.js | 4 + 11 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 src/layers/streetLevel/providerSwitches.js create mode 100644 src/layers/streetLevel/providerSwitches.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a2620b1..b2a4906ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Custom Street Level provider switches survive share links and stored state + using stable provider IDs; existing Mapillary links keep their meaning. + - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. Explicitly removing either source leaves its layer unavailable; standalone diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27a5815e9..696c94d35 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -24,8 +24,13 @@ all imagery providers. Without that option, Mapillary is registered only when with a truthful no-provider state, preserving layer controls and share tokens. Malformed selected sources and provider definitions still fail validation. An explicit provider list bypasses the unused Mapillary source, including its -validation. Layer share identities are preserved; custom provider switches require -a corresponding serialization contract. +validation. Layer share identities and the legacy Mapillary `m` option remain +stable. Custom provider switches use the `r` option, sorted by provider ID rather +than registration order, and round-trip through links and stored state. A receiver +applies only switches for its registered providers; absent custom switches leave +current defaults unchanged. The custom switch field is bounded to 256 characters; +provider registration rejects compositions that cannot fit rather than truncating +identities. Existing whole-link size limits still apply. ## God's Eye View in conversations — October 2, 2026 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5b10693ab..72a8217c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -552,6 +552,7 @@ "src/contextModePolicy.js", "src/data/detectionPolicy.js", "src/data/installationFeedback.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -800,6 +801,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -842,6 +844,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -879,6 +882,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1204,6 +1208,7 @@ "src/layers/streetLevel/groundCast.js", "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", + "src/layers/streetLevel/providerSwitches.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", @@ -1249,6 +1254,7 @@ "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1537,6 +1543,7 @@ "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -2286,6 +2293,7 @@ "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", @@ -2807,6 +2815,7 @@ "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3327,6 +3336,7 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", @@ -3449,6 +3459,7 @@ "exports": ["./view"], "modules": [ "src/view/index.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/layerState.js b/src/data/layerState.js index 92a18179c..fad2f112e 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,3 +1,4 @@ +import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -411,8 +412,16 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('viirs', 'v', false), ]), 'street-level': Object.freeze([ - // One switch per registered imagery provider (src/app/layers/streetLevel.js). + // Preserve the published Mapillary token; custom switches use stable IDs. booleanOption('mapillary', 'm', true), + Object.freeze({ + key: 'providerSwitches', + token: 'r', + defaultValue: '', + normalize: normalizeProviderSwitches, + encode: normalizeProviderSwitches, + decode: normalizeProviderSwitches, + }), enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], { all: 'a', pano: 'p', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1ece6750f..7377bdaa4 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2660,12 +2660,14 @@ test('Street Level: every option round-trips through a share link and stored sta state.enabledLayerIds = ['street-level']; state.options['street-level'] = { mapillary: false, + providerSwitches: '', pano: 'flat', sinceDays: MAX_SINCE_DAYS, }; const decoded = decodeLayerStateParams(new URLSearchParams(encode(state))); assert.deepEqual(decoded.options['street-level'], { mapillary: false, + providerSwitches: '', pano: 'flat', sinceDays: MAX_SINCE_DAYS, }); @@ -2693,6 +2695,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps' test('Street Level: a link without the provider switch keeps Mapillary on', () => { assert.deepEqual(streetLevelOptions(''), { mapillary: true, + providerSwitches: '', pano: 'all', sinceDays: 0, }); diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 95e827416..4b8b23c04 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -98,6 +98,17 @@ function withPublishedBase( } function writeCodecFixture(cwd, rows, entries) { + const providerCodec = path.join( + cwd, + 'src/layers/streetLevel/providerSwitches.js', + ); + mkdirSync(path.dirname(providerCodec), { recursive: true }); + writeFileSync( + providerCodec, + readFileSync( + new URL('../layers/streetLevel/providerSwitches.js', import.meta.url), + ), + ); const source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; assert.equal(source.split(registryEnd).length, 2); diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 1d87b1ebc..8af631ffc 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -1,13 +1,20 @@ import { normalizeFilter } from './filter.js'; +import { + encodeProviderSwitches, + decodeProviderSwitches, +} from './providerSwitches.js'; /** - * Share-link params: one boolean per provider plus the filter. The codec in - * src/data/layerState.js names the same keys, so a new provider needs one there. + * Provider params plus the filter. Custom switches share a stable ID-based + * field; Mapillary retains its existing boolean and share token. * @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input */ export function encodeParams({ providers, filter }) { const params = {}; - for (const [id, on] of providers) params[id] = on === true; + const entries = [...providers]; + for (const [id, on] of entries) params[id] = on === true; + const switches = encodeProviderSwitches(entries); + if (switches) params.providerSwitches = switches; params.pano = filter.pano; params.sinceDays = filter.sinceDays; return params; @@ -23,8 +30,12 @@ export function encodeParams({ providers, filter }) { export function decodeParams(params, { providerIds, filter }) { const providers = new Map(); const source = params && typeof params === 'object' ? params : {}; - for (const id of providerIds) - if (typeof source[id] === 'boolean') providers.set(id, source[id]); + const switches = decodeProviderSwitches(source.providerSwitches); + 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') + providers.set(id, source[id]); + } const next = {}; if ('pano' in source) next.pano = source.pano; if ('sinceDays' in source) next.sinceDays = source.sinceDays; diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs index 19a89a1ca..856f33d97 100644 --- a/src/layers/streetLevel/params.test.mjs +++ b/src/layers/streetLevel/params.test.mjs @@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => { ], filter: { pano: 'flat', sinceDays: 365 }, }), - { mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 }, + { + mapillary: true, + panoramax: false, + providerSwitches: 'panoramax=0', + pano: 'flat', + sinceDays: 365, + }, ); }); diff --git a/src/layers/streetLevel/providerSwitches.js b/src/layers/streetLevel/providerSwitches.js new file mode 100644 index 000000000..cb1237542 --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.js @@ -0,0 +1,45 @@ +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']; + }) + : [], + ); +} diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs new file mode 100644 index 000000000..96253dfab --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeParams, decodeParams } from './params.js'; +import { + normalizeProviderSwitches, + encodeProviderSwitches, +} from './providerSwitches.js'; +import { + createDefaultLayerState, + encodeLayerStateParams, + decodeLayerStateParams, + serializeStoredLayerState, + parseStoredLayerState, +} from '../../data/layerState.js'; +import { validateProviders } from './registry.js'; +import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs'; + +const filter = { pano: 'flat', sinceDays: 365 }; + +test('custom switches survive sharing and storage without depending on provider order', () => { + const providers = [ + ['panoramax', false], + ['example', true], + ['mapillary', false], + ]; + const options = encodeParams({ providers, filter }); + assert.equal(options.providerSwitches, 'example=1,panoramax=0'); + assert.equal( + encodeParams({ providers: [...providers].reverse(), filter }) + .providerSwitches, + options.providerSwitches, + ); + const state = createDefaultLayerState(); + state.enabledLayerIds = ['street-level']; + state.options['street-level'] = options; + 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/); + for (const restored of [ + decodeLayerStateParams(params), + parseStoredLayerState(serializeStoredLayerState(state)), + ]) { + const decoded = decodeParams(restored.options['street-level'], { + providerIds: ['example', 'mapillary', 'panoramax'], + filter: { pano: 'all', sinceDays: 0 }, + }); + assert.deepEqual( + [...decoded.providers], + [ + ['example', true], + ['mapillary', false], + ['panoramax', false], + ], + ); + assert.deepEqual(decoded.filter, filter); + assert.deepEqual( + [ + ...decodeParams(restored.options['street-level'], { + providerIds: ['panoramax'], + filter, + }).providers, + ], + [['panoramax', false]], + ); + assert.equal( + decodeParams(restored.options['street-level'], { + providerIds: [], + filter, + }).providers.size, + 0, + ); + } +}); + +test('legacy links retain Mapillary semantics and leave custom defaults alone', () => { + for (const [lo, on] of [ + ['', true], + ['0.m.0', false], + ]) { + const state = decodeLayerStateParams( + new URLSearchParams({ v: '2', l: '0', lo }), + ); + const restored = decodeParams(state.options['street-level'], { + providerIds: ['mapillary', 'example'], + filter, + }); + assert.deepEqual([...restored.providers], [['mapillary', on]]); + } +}); + +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`, + null, + {}, + ]) { + assert.equal(normalizeProviderSwitches(value), null); + } + assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0'); + assert.throws( + () => encodeProviderSwitches([['a'.repeat(255), true]]), + /oversized/, + ); + assert.throws( + () => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]), + /oversized/, + ); + const decoded = decodeParams( + { providerSwitches: 'mapillary=0', mapillary: true }, + { providerIds: ['mapillary'], filter }, + ); + assert.equal(decoded.providers.get('mapillary'), true); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index b6b81fe35..aefb14ae1 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,3 +1,5 @@ +import { encodeProviderSwitches } from './providerSwitches.js'; + /** * The contract an imagery provider registers with the Street Level layer. * @@ -125,6 +127,8 @@ export function validateProviders(providers) { ids.add(provider.id); prefixes.push(provider.pickPrefix); } + // Validate the complete bounded share field once, before any instance exists. + encodeProviderSwitches(providers.map(({ id }) => [id, true])); return Object.freeze([...providers]); } From 3ba8c6d61b74a36519fc7a563bf30b93c293b70e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 18:47:04 -0700 Subject: [PATCH 2/3] Use a neutral compact switch codec and clarify provider size limits --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 6 +- scripts/package-boundaries.json | 22 ++++---- src/data/idSwitches.js | 47 ++++++++++++++++ src/data/layerState.js | 8 +-- src/data/layerStateTokenLedger.test.mjs | 9 +-- src/layers/streetLevel/params.js | 12 ++-- src/layers/streetLevel/params.test.mjs | 2 +- src/layers/streetLevel/providerSwitches.js | 45 --------------- .../streetLevel/providerSwitches.test.mjs | 56 ++++++++++++------- src/layers/streetLevel/registry.js | 15 ++++- 11 files changed, 127 insertions(+), 98 deletions(-) create mode 100644 src/data/idSwitches.js delete mode 100644 src/layers/streetLevel/providerSwitches.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e172707..0bebc8806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog - Custom Street Level provider switches survive share links and stored state - using stable provider IDs; existing Mapillary links keep their meaning. + using URL-safe stable provider IDs and a shared boolean-switch codec; existing + Mapillary links keep their meaning. - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a3aeeee03..f2ad3225e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -27,12 +27,14 @@ with a truthful no-provider state, preserving layer controls and share tokens. Malformed selected sources and provider definitions still fail validation. An explicit provider list bypasses the unused Mapillary source, including its validation. Layer share identities and the legacy Mapillary `m` option remain -stable. Custom provider switches use the `r` option, sorted by provider ID rather +stable. Custom provider switches use the `r` option (`example-1*panoramax-0`), +with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather than registration order, and round-trip through links and stored state. A receiver applies only switches for its registered providers; absent custom switches leave current defaults unchanged. The custom switch field is bounded to 256 characters; provider registration rejects compositions that cannot fit rather than truncating -identities. Existing whole-link size limits still apply. +identities. A malformed or duplicate entry invalidates the complete custom-switch +field. Existing whole-link size limits still apply. ## God's Eye View in conversations — October 2, 2026 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 72a8217c6..497be5b08 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -552,7 +552,7 @@ "src/contextModePolicy.js", "src/data/detectionPolicy.js", "src/data/installationFeedback.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -801,7 +801,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -844,7 +844,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -882,7 +882,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1208,7 +1208,7 @@ "src/layers/streetLevel/groundCast.js", "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", @@ -1254,7 +1254,7 @@ "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1543,7 +1543,7 @@ "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -2293,7 +2293,7 @@ "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", @@ -2815,7 +2815,7 @@ "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3336,7 +3336,7 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", @@ -3459,7 +3459,7 @@ "exports": ["./view"], "modules": [ "src/view/index.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js new file mode 100644 index 000000000..7b75b5da4 --- /dev/null +++ b/src/data/idSwitches.js @@ -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']; + }) + : [], + ); +} diff --git a/src/data/layerState.js b/src/data/layerState.js index fad2f112e..50b61c66c 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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', diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 4b8b23c04..5a02d85cf 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -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'; diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 8af631ffc..4aed3ad8f 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -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') diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs index 856f33d97..ffe56272f 100644 --- a/src/layers/streetLevel/params.test.mjs +++ b/src/layers/streetLevel/params.test.mjs @@ -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, }, diff --git a/src/layers/streetLevel/providerSwitches.js b/src/layers/streetLevel/providerSwitches.js deleted file mode 100644 index cb1237542..000000000 --- a/src/layers/streetLevel/providerSwitches.js +++ /dev/null @@ -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']; - }) - : [], - ); -} diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs index 96253dfab..78abe6d3e 100644 --- a/src/layers/streetLevel/providerSwitches.test.mjs +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -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); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index aefb14ae1..29ec24869 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -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]); } From eacfc1b06a9a95b57df3f59951960f5233e0b773 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 19:09:30 -0700 Subject: [PATCH 3/3] Persist explicit provider toggles through the layer state coordinator --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 4 +- src/data/idSwitches.js | 16 +++ src/data/layerState.js | 3 +- src/data/layerState.test.mjs | 117 +++++++++++++++--- src/data/layerStateCoordinator.js | 18 ++- .../streetLevel/providerSwitches.test.mjs | 14 +++ 7 files changed, 152 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bebc8806..a93296425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ - Custom Street Level provider switches survive share links and stored state using URL-safe stable provider IDs and a shared boolean-switch codec; existing - Mapillary links keep their meaning. + Mapillary links keep their meaning. Explicit provider toggles reach durable + state without adopting unrelated live switch changes. - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f2ad3225e..dc9a9278b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -31,7 +31,9 @@ stable. Custom provider switches use the `r` option (`example-1*panoramax-0`), with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather than registration order, and round-trip through links and stored state. A receiver applies only switches for its registered providers; absent custom switches leave -current defaults unchanged. The custom switch field is bounded to 256 characters; +current defaults unchanged. Explicit provider toggles persist only their requested +switch IDs through the state coordinator; unrelated live values remain transient. +The custom switch field is bounded to 256 characters; provider registration rejects compositions that cannot fit rather than truncating identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js index 7b75b5da4..0fe8f8de0 100644 --- a/src/data/idSwitches.js +++ b/src/data/idSwitches.js @@ -45,3 +45,19 @@ export function decodeIdSwitches(value) { : [], ); } + +/** Persist only explicitly requested switch IDs, not unrelated live changes. */ +export function patchRequestedIdSwitches(previous, applied, requested) { + const current = decodeIdSwitches(applied); + const next = new Map( + [...decodeIdSwitches(previous)].filter(([id]) => current.has(id)), + ); + let changed = false; + for (const [id, on] of current) { + if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') { + next.set(id, on); + changed = true; + } + } + return changed ? encodeIdSwitches(next) : undefined; +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 50b61c66c..373eac20d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,4 +1,4 @@ -import { normalizeIdSwitches } from './idSwitches.js'; +import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -416,6 +416,7 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('mapillary', 'm', true), Object.freeze({ key: 'providerSwitches', + patchRequested: patchRequestedIdSwitches, token: 'r', defaultValue: '', normalize: (value) => normalizeIdSwitches(value, ['mapillary']), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7377bdaa4..08120a604 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,3 +1,4 @@ +import { encodeParams, decodeParams } from '../layers/streetLevel/params.js'; import { readShellSource } from '../testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../../build/application-html.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; @@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts' } assert.equal(nextLayerStateToken(), '3'); assert.equal( - nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }), + nextLayerStateToken({ + ...LAYER_STATE_TOKEN_RESERVATIONS, + alpha: '3', + bravo: '4', + }), '5', ); const digitsExhausted = { @@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts' [ ...'0123456789', ...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) => - [...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`), + [...LAYER_STATE_TOKEN_ALPHABET].map( + (second) => `${first}${second}`, + ), ), ].map((token, index) => [`occupied-${index}`, token]), ), @@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts' true, ); assert.equal( - validateLayerStateAllocations( - LAYER_STATE_TOKEN_RESERVATIONS, - { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, - ), + 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', - }), + () => + 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' }, - ), + validateLayerStateAllocations(beforeLastDigit, { + ...beforeLastDigit, + futurePair: '00', + futureDigit: '9', + }), true, ); assert.equal( - validateLayerStateAllocations( - digitsExhausted, - { ...digitsExhausted, pairB: '01', pairA: '00' }, - ), + validateLayerStateAllocations(digitsExhausted, { + ...digitsExhausted, + pairB: '01', + pairA: '00', + }), true, ); assert.throws( @@ -2706,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () = true, ); }); + +test('explicit provider switches reach durable state without adopting unrelated live switches', () => { + const manager = new DataLayerManager({}); + const providers = new Map([ + ['mapillary', true], + ['alpha', true], + ['beta', true], + ]); + const filter = { pano: 'all', sinceDays: 0 }; + for (const id of REGISTERED_LAYER_IDS) { + manager.register( + id === 'street-level' + ? { + ...fakeLayer(id), + getParams: () => encodeParams({ providers, filter }), + setParams(params) { + const decoded = decodeParams(params, { + providerIds: providers.keys(), + filter, + }); + for (const [id, on] of decoded.providers) providers.set(id, on); + return true; + }, + } + : fakeLayer(id), + ); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + try { + providers.set('beta', false); // Passive state does not belong to the next action. + assert.equal( + manager.setLayerParams( + 'street-level', + { alpha: false }, + { origin: 'user' }, + ), + true, + ); + let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0'); + const shared = decodeLayerStateParams(new URLSearchParams(encode(saved))); + const restored = decodeParams(shared.options['street-level'], { + providerIds: ['beta', 'alpha'], + filter, + }); + assert.deepEqual([...restored.providers], [['alpha', false]]); + manager.setLayerParams('street-level', { beta: true }, { origin: 'user' }); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + manager.setLayerParams( + 'street-level', + { alpha: true }, + { origin: 'local-restore' }, + ); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + } finally { + coordinator.destroy(); + } +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 86b6af8e7..687b87dfb 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -255,8 +255,22 @@ export class LayerStateCoordinator { const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey; - if (!explicitlyRequested && !implicitTrackingSync) continue; - const value = spec.normalize(change.params[spec.key]); + // Packed options can persist only the subkeys owned by this request. + const requestedValue = + explicitlyRequested || implicitTrackingSync + ? change.params[spec.key] + : spec.patchRequested?.( + nextOwnerOptions[spec.key], + change.params[spec.key], + requestedParams, + ); + if ( + !explicitlyRequested && + !implicitTrackingSync && + requestedValue === undefined + ) + continue; + const value = spec.normalize(requestedValue); if (value === null) { if (implicitTrackingSync) { nextOwnerOptions[spec.key] = null; diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs index 78abe6d3e..03a161dcb 100644 --- a/src/layers/streetLevel/providerSwitches.test.mjs +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { encodeParams, decodeParams } from './params.js'; import { normalizeIdSwitches, + patchRequestedIdSwitches, decodeIdSwitches, encodeIdSwitches, } from '../../data/idSwitches.js'; @@ -137,3 +138,16 @@ test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => for (const [id, on] of switches) assert.equal(decoded.get(id), on); assert.equal(decodeIdSwitches('valid-1*broken').size, 0); }); + +test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => { + const previous = `${'x'.repeat(254)}-1`; + assert.equal(normalizeIdSwitches(previous), previous); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }), + 'alpha-0', + ); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }), + undefined, + ); +});