diff --git a/README.md b/README.md index 3a4c5fc43..dcd9ff400 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat ## ⚡ Quick Start -**Start without an account or API keys.** Both paths open the same app with +**Start without an account or API keys.** Every path opens the same app with Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is unreachable. Flights, military traffic, satellites, earthquakes, public cameras, radio, and launches are available without keys. @@ -233,6 +233,41 @@ the container. +### Path 4 — GitHub Codespaces (cloud, no local install) + +Runs the same dev server in a cloud workspace, so Node and the dependencies +never touch your machine. The globe still renders in your own browser, so it +needs the same graphics there as any other path. GitHub Free includes 120 +core-hours a month with no card on file — about 60 hours on the default 2-core +machine. Org and Enterprise plans include no free quota; current terms and +quotas apply. + +1. Fork this repository, then open **your fork** on GitHub. +2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. +3. In the codespace terminal: + ```bash + nvm install 24 && nvm use 24 + npm ci + npm run doctor + GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev + ``` +4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in + the browser. The port is private by default, so the URL asks for your GitHub + login. Keep it private: a public port lets anyone use the app and the keys + in your `.env`. + +`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only +the hostnames it trusts, and a forwarded port arrives on +`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the +page is `Blocked request. This host is not allowed.` Leading-dot wildcards are +ignored on purpose, so pass that exact name. + +⚠️ **A codespace is reachable from the internet.** Treat it as the +wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider +Settings does not open on the forwarded URL, so put keys in the workspace +`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +threat model. + ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** diff --git a/changelog.d/1093-multipart-cable-feature-ownership.md b/changelog.d/1093-multipart-cable-feature-ownership.md new file mode 100644 index 000000000..777a159d0 --- /dev/null +++ b/changelog.d/1093-multipart-cable-feature-ownership.md @@ -0,0 +1,4 @@ +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature + (@Aliciawque, #1093). diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md new file mode 100644 index 000000000..35d9e73e7 --- /dev/null +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -0,0 +1,4 @@ +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site and reverse-proxy-forwarded browser requests with 403 before any + Overpass work, through the same shared gate as `/api/overpass`. Same-origin + app requests and local tools such as curl still work (@Sunil56224972, #1427). diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md new file mode 100644 index 000000000..ae0b1be38 --- /dev/null +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -0,0 +1,5 @@ +- Leave layer aliases the layer id already spells, such as "dams" for + `local-dams`, out of the voice layer hint. The Realtime prompt is 305 + characters shorter, leaving room under the 52,000 gate for new + voice-toggleable layers; every alias is still accepted (@quanticsoul4772, + #1462). diff --git a/changelog.d/980-codespaces-setup.md b/changelog.d/980-codespaces-setup.md new file mode 100644 index 000000000..57acd7094 --- /dev/null +++ b/changelog.d/980-codespaces-setup.md @@ -0,0 +1,3 @@ +- Document GitHub Codespaces as a fourth setup path in the README: a cloud + workspace runs the dev server while the globe renders in your own browser + (John Leshan, @johnleshan, #980). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c6accd22c..b27899f3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5100,6 +5100,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. +Both query handlers refuse cross-site and forwarded browser requests through +the shared local-request gate, before any rate-limit, cache or upstream work. With no configured upstream, each route serves existing cache data (including expired entries, marked stale with the original cache/retrieval date) without refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`, @@ -6387,6 +6389,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. +Every Cesium entity from multipart cable or landing-point geometry stays tied +to its source feature, including generated IDs and IDs that collide with +Cesium's multipart suffixes. Each segment gets its own feature's styling, click +destination and horizon-culling reference, and each source feature has one +reference stem. The source collections are not modified. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 303107647..217628822 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -432,7 +432,9 @@ "server/providers/overpass/transport.js", "src/sources/httpBody.js", "src/i18n/marker.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 7d252b8ed..1b6c82107 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -1,4 +1,5 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, @@ -97,6 +98,7 @@ function militaryInstallationsProxy() { res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } + if (admitSameSite(req, res)) return; const url = new URL(req.url, 'http://localhost'); const requested = validMilitaryInstallationBox(url.searchParams); if (!requested) { diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 56548c1da..81c1cfda5 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1029,10 +1029,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Panel adds timeout and named voice/provider targets; consolidated guidance // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. - assert.equal(block.length, 31206, 'serialized tool schema length drifted'); + // The layer alias hint leaves out aliases the layer id already spells. + assert.equal(block.length, 30901, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746', + '9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index 5468996d5..714b8753b 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -6,6 +6,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), diff --git a/src/overpassOffload.test.mjs b/src/overpassOffload.test.mjs index 602bab8e4..7ef6b0c2b 100644 --- a/src/overpassOffload.test.mjs +++ b/src/overpassOffload.test.mjs @@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) { Object.assign(req, { method: init.method || 'GET', url: parsed.pathname.slice(mount.length) + parsed.search || '/', - headers: {}, + headers: init.headers || {}, socket: { remoteAddress: '127.0.0.1' }, }); - let status, headers, body; + let status, body; + let headers = {}; await handlers.get(mount)(req, { + // The cross-site gate answers through statusCode/setHeader, not writeHead. + set statusCode(s) { + status = s; + }, + setHeader(name, value) { + headers[name] = value; + }, writeHead(s, h) { status = s; - headers = h; + headers = { ...headers, ...h }; }, end(b) { body = b; @@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re ); }); +test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => { + env(t); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const handlers = routes(militaryInstallationsProxy()); + const url = + '/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4'; + for (const headers of [ + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'https://foreign.example' }, + { origin: 'null' }, + { 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + ]) { + const response = await call(handlers, url, { headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + ]) { + const response = await call(handlers, url, { + headers: { host: 'localhost:4173', ...headers }, + }); + assert.notEqual(response.status, 403, JSON.stringify(headers)); + } +}); + test('an unconfigured server is probed once and never sent a feature query', async (t) => { env(t); t.mock.method(globalThis, 'fetch', () => diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 4e6586729..6f16f7606 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // is consolidated without changing either feature's arguments. // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. - 'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b', + // The layer alias hint leaves out aliases the layer id already spells. + 'e65812db60a2702cebb496fca257cb04a28f1bbbf250cb9ab65a3db33005e0c8', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 267399e58..962bf6949 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,22 +525,31 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', - // The brand is already the layer id; all spoken aliases remain accepted. - 's2-ghostmaps', ]); +/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */ +const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + /** * Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers * whose id is not already the spoken word. At most two aliases per layer keep - * the tool JSON small; the runner accepts the full alias list. + * the tool JSON small; the runner accepts the full alias list. Of those two, + * an alias the id already spells ("dams"→local-dams, "weather radar"→ + * weather-radar) is left out, and a layer with neither left gets no hint. * @returns {string} One line for the layerId description. */ export function voiceLayerAliasHint() { return VOICE_LAYER_MANIFEST.filter( - (entry) => - entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), + (entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id), ) - .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) + .map((entry) => ({ + id: entry.id, + aliases: entry.aliases + .slice(0, 2) + .filter((alias) => !spelled(entry.id).includes(spelled(alias))), + })) + .filter((entry) => entry.aliases.length) + .map((entry) => `${entry.aliases.join('/')}→${entry.id}`) .join(';'); } diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..ab382c460 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -9,6 +9,7 @@ import { VOICE_TOGGLE_LAYER_IDS, VOICE_CONTEXT_LAYER_IDS, VOICE_QUERY_LAYER_IDS, + voiceLayerAliasHint, voiceLayerAliasMap, } from './layerManifest.js'; @@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => { assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); }); +test('the alias hint leaves out names the layer id already spells', () => { + const hint = voiceLayerAliasHint(); + const entries = hint.split(';').map((entry) => entry.split('→')); + const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + for (const [aliases, id] of entries) { + for (const alias of aliases.split('/')) { + assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`); + } + } + const ids = entries.map(([, id]) => id); + assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry'); + assert.ok(!ids.includes('weather-radar')); + assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays'); + assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/); + assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/); +}); + test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);