Every runtime CDN script and stylesheet is an exact version with an integrity hash

maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime loader set `integrity`,
so a CDN could serve any bytes to every partner's visitors unnoticed (CRITIC-5).

- library/constants/cdn.js is the one registry: mapbox-gl 3.24.0, maplibre-gl 5.24.0 (the release the
  @5 range already resolved to), both geocoders (1.9.4 / 5.0.0) and d3 7.9.0, each with its sha384.
  Every host answers Access-Control-Allow-Origin: *, so integrity + crossorigin work cross-origin.
- ensureEngineScript / ensureEngineCSS, the geocoder loaders (library and PPLR) and the generator's
  static MapLibre tags set integrity + crossorigin from it; the embed snippet's engine preload matches.
- check:cdn-pins (gates:post): no floating CDN url in shipped code, and every one is in the registry;
  --online re-fetches each and compares the hash (all 9 match today).

MapLibre stays on 5.x knowingly. GHSA-jrc7-96c5-q579 (DOM.sanitize, reached through a source's
attribution string) affects <= 6.4.0, and 6.x is ESM-only (no UMD global), so the upgrade is a loader
migration of its own. The attribution strings rendered today come from our configured basemap styles
and our own sources; the note in cdn.js says who is exposed until then.
This commit is contained in:
Doxa Maps 2026-09-26 05:49:18 +00:00
parent 71858d713c
commit 4a12b009aa
6 changed files with 127 additions and 35 deletions

View file

@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* check-cdn-pins — every runtime third-party script/stylesheet is an EXACT version with an integrity hash.
*
* CRITIC-5 (2026-09-26): maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime
* loader set `integrity`, so a CDN could serve any bytes to every partner's visitors unnoticed.
* library/constants/cdn.js is now the one registry; loaders set integrity + crossorigin from it.
*
* OFFLINE (default, in gates:post): in shipped code (library/, plugin-designs/ except scripts/,
* internal/generate-tree.mjs) no jsDelivr/unpkg/esm.sh/api.mapbox.com JS or CSS URL is a floating
* range (@7, @1.2, latest), and every such URL is either in the registry or on the reviewed list below.
* --online: also re-fetches every registry URL and requires its sha384 to equal the pinned hash.
* EXIT 0 pass · 1 fail · 75 (online) network unavailable
*/
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { CDN } from '../library/constants/cdn.js'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const ONLINE = process.argv.includes('--online')
// Loaded through something other than a <script>/<link> tag, so integrity cannot apply; pinned exactly.
const REVIEWED = new Map([
['https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
['https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
])
const known = new Set(Object.values(CDN).map((e) => e.url))
const URL_RE = /https:\/\/(?:cdn\.jsdelivr\.net|unpkg\.com|esm\.sh|api\.mapbox\.com\/mapbox-gl-js)\/[^\s'"`)<>]+?\.(?:m?js|css)\b/g
const FLOATING = /@(?:\d+|\d+\.\d+|latest|next)\/|@\^|@~/
const files = execFileSync('git', ['ls-files', '--', 'library', 'plugin-designs', 'internal/generate-tree.mjs'], { cwd: ROOT, encoding: 'utf8' })
.split('\n').filter((f) => /\.(m?js|vue|html)$/.test(f) && !/\/scripts\//.test(f) && !/\/index\.html$/.test(f) && !/^plugin-designs\/[^/]+\/data\//.test(f))
let bad = 0
const seen = new Map()
for (const f of files) {
const src = execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 })
for (const m of src.matchAll(URL_RE)) {
const u = m[0]
if (!seen.has(u)) seen.set(u, f)
if (FLOATING.test(u)) { bad++; console.log(` FAIL ${f}: floating version ${u}`) }
else if (!known.has(u) && !REVIEWED.has(u)) { bad++; console.log(` FAIL ${f}: not in library/constants/cdn.js (no integrity): ${u}`) }
}
}
console.log(`CDN-PINS: ${seen.size} distinct runtime CDN url(s) in shipped code; registry holds ${known.size}.`)
if (ONLINE) {
for (const [k, e] of Object.entries(CDN)) {
let got = ''
try { const r = await fetch(e.url); if (!r.ok) throw new Error('HTTP ' + r.status); got = 'sha384-' + createHash('sha384').update(Buffer.from(await r.arrayBuffer())).digest('base64') }
catch (err) { console.log(`CDN-PINS: NOT MEASURED — ${e.url}: ${err.message}`); process.exit(75) }
const ok = got === e.integrity
if (!ok) bad++
console.log(` ${ok ? 'ok ' : 'FAIL'} ${k}: ${ok ? 'bytes match the pinned hash' : `served ${got}, pinned ${e.integrity}`}`)
}
}
if (bad) { console.log(`CDN-PINS: FAIL — ${bad} problem(s).`); process.exit(1) }
console.log(`CDN-PINS: PASS — every runtime CDN script/stylesheet is an exact version${ONLINE ? ', and every pinned hash matches what the CDN serves' : ' with an integrity hash'}.`)

View file

@ -30,6 +30,7 @@
* Run standalone: bun generate-tree.mjs (expects vite build already ran) * Run standalone: bun generate-tree.mjs (expects vite build already ran)
* Wired into: bun run build (runs after the per-bundle vite builds) * Wired into: bun run build (runs after the per-bundle vite builds)
*/ */
import { CDN } from '../library/constants/cdn.js'
import { import {
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync, readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
cpSync, rmSync, mkdtempSync, cpSync, rmSync, mkdtempSync,
@ -167,15 +168,7 @@ const SCAFFOLD_BUNDLES = ['template-bundle']
// ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js), // ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js),
// so the embed snippet's preload can never point at a different version than the one the bundle // so the embed snippet's preload can never point at a different version than the one the bundle
// requests — a mismatched preload is not used, and the engine would download twice. // requests — a mismatched preload is not used, and the engine would download twice.
const MAPBOX_GL_JS = (() => { const MAPBOX_GL_JS = CDN.mapboxGlJs.url // the ONE registry the bundle's loader reads too (library/constants/cdn.js)
try {
const src = readFileSync(resolve(__dirname, '..', 'library', 'composables', 'useMapInstance.js'), 'utf8')
const m = src.match(/mapbox:\s*\{[^}]*?js:\s*'([^']+mapbox-gl\.js)'/)
if (m) return m[1]
} catch { /* fall through */ }
console.warn('[generate-tree] could not read the Mapbox engine URL from useMapInstance.js; embed snippets get no preload')
return ''
})()
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name) const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
@ -807,8 +800,8 @@ function mapPageHtml(opts) {
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element ? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). --> uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" /> <link href="${CDN.maplibreGlCss.url}" rel="stylesheet" integrity="${CDN.maplibreGlCss.integrity}" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js"></script>` <script src="${CDN.maplibreGlJs.url}" integrity="${CDN.maplibreGlJs.integrity}" crossorigin="anonymous"></script>`
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the : `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time — geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
@ -1306,15 +1299,14 @@ function scanBundles() {
// pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only // pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only
// after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right // after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right
// after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download // after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download
// alongside the bundle. NO crossorigin attribute, on purpose: ensureEngineScript injects a plain // alongside the bundle. It carries crossorigin + integrity because ensureEngineScript's <script> now
// <script src>, and a preload is only reused when its request mode matches — with crossorigin it // does too (library/constants/cdn.js): a preload is reused only when its request mode matches. Mapbox only: a non-map profile gets none, and a partner switching to
// would download twice. Mapbox only: a non-map profile gets none, and a partner switching to
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests. // provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too. // The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
embed: { embed: {
iframe: iframeEmbed, iframe: iframeEmbed,
script: isMap && MAPBOX_GL_JS script: isMap && MAPBOX_GL_JS
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed ? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}" integrity="${CDN.mapboxGlJs.integrity}" crossorigin="anonymous">\n` + scriptEmbed
: scriptEmbed, : scriptEmbed,
}, },
...(instances.length ? { instances } : {}), ...(instances.length ? { instances } : {}),

View file

@ -62,6 +62,7 @@
geocoder — the raw MapboxGeocoder instance (for advanced usage) geocoder — the raw MapboxGeocoder instance (for advanced usage)
--> -->
<script setup> <script setup>
import { CDN, pinElement } from '@map/constants/cdn.js'
import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue' import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useEntitySearch } from '../../composables/useSearch.js' import { useEntitySearch } from '../../composables/useSearch.js'
@ -380,16 +381,16 @@ const standaloneContainer = ref(null)
// the document head has no effect because the widget renders inside a // the document head has no effect because the widget renders inside a
// ShadowRoot. We target the closest ShadowRoot of the map container. // ShadowRoot. We target the closest ShadowRoot of the map container.
const GEOCODER_CSS = { const GEOCODER_CSS = {
mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', mapbox: CDN.mapboxGeocoderCss.url,
maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css', maplibre: CDN.maplibreGeocoderCss.url,
} }
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via // MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
// index.html; MapLibre's is injected on demand so a profile can flip provider // index.html; MapLibre's is injected on demand so a profile can flip provider
// with no host change. // with no host change.
const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js' const MAPLIBRE_GEOCODER_JS = CDN.maplibreGeocoderJs.url // exact version + SRI: library/constants/cdn.js
// Same version the generated leaf pages preload, so an on-demand load and a preload cannot end up // Same version the generated leaf pages preload, so an on-demand load and a preload cannot end up
// on two different geocoders. // on two different geocoders.
const MAPBOX_GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js' const MAPBOX_GEOCODER_JS = CDN.mapboxGeocoderJs.url
function injectGeocoderCSS(shadowRoot, prov) { function injectGeocoderCSS(shadowRoot, prov) {
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
@ -397,6 +398,7 @@ function injectGeocoderCSS(shadowRoot, prov) {
const link = document.createElement('link') const link = document.createElement('link')
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = HREF link.href = HREF
pinElement(link, HREF)
shadowRoot.prepend(link) shadowRoot.prepend(link)
} }
@ -433,6 +435,7 @@ async function ensureGeocoderScript(src, globalName) {
await new Promise((resolve) => { await new Promise((resolve) => {
const el = document.createElement('script') const el = document.createElement('script')
el.src = src el.src = src
pinElement(el, src)
el.async = true el.async = true
// Resolve on error too: a blocked CDN must leave the map usable and the bar absent, which is // Resolve on error too: a blocked CDN must leave the map usable and the bar absent, which is
// what happened before this existed — never a hang or an unhandled rejection. // what happened before this existed — never a hang or an unhandled rejection.

View file

@ -32,17 +32,10 @@ import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html // The map engine is loaded as a page global (CDN <script>). Profiles / index.html
// may preload it; if absent we inject it here. Mapbox is the default — its URLs // may preload it; if absent we inject it here. Mapbox is the default — its URLs
// match the existing <script> tags so nothing double-loads or changes. // match the existing <script> tags so nothing double-loads or changes.
// URLs and their integrity hashes come from ONE place (library/constants/cdn.js): exact versions, SRI.
const ENGINES = { const ENGINES = {
mapbox: { mapbox: { global: 'mapboxgl', js: CDN.mapboxGlJs.url, css: CDN.mapboxGlCss.url },
global: 'mapboxgl', maplibre: { global: 'maplibregl', js: CDN.maplibreGlJs.url, css: CDN.maplibreGlCss.url },
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
},
maplibre: {
global: 'maplibregl',
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
},
} }
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a // ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
@ -76,6 +69,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
// 'midnight-glow' / … resolves per engine here; raw URLs pass through. // 'midnight-glow' / … resolves per engine here; raw URLs pass through.
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js' import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js' import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
import { CDN, pinElement } from '../constants/cdn.js'
import { installRtlTextPlugin } from '../maps/rtl-text.js' import { installRtlTextPlugin } from '../maps/rtl-text.js'
import { localizeBasemapLabels } from '../maps/localize-labels.js' import { localizeBasemapLabels } from '../maps/localize-labels.js'
@ -130,6 +124,7 @@ export async function ensureEngineScript(provider) {
const link = document.createElement('link') const link = document.createElement('link')
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = cfg.css link.href = cfg.css
pinElement(link, cfg.css)
document.head.appendChild(link) document.head.appendChild(link)
} }
@ -146,6 +141,7 @@ export async function ensureEngineScript(provider) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const s = document.createElement('script') const s = document.createElement('script')
s.src = cfg.js s.src = cfg.js
pinElement(s, cfg.js)
s.async = false s.async = false
s.addEventListener('load', resolve, { once: true }) s.addEventListener('load', resolve, { once: true })
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true }) s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
@ -196,6 +192,7 @@ export async function ensureEngineCSS(element, cssUrl) {
const link = document.createElement('link') const link = document.createElement('link')
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = cssUrl link.href = cssUrl
pinElement(link, cssUrl)
await Promise.race([ await Promise.race([
new Promise(resolve => { new Promise(resolve => {

44
library/constants/cdn.js Normal file
View file

@ -0,0 +1,44 @@
/**
* cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version
* with its Subresource Integrity hash (CRITIC-5, 2026-09-26).
*
* WHY. Floating ranges (maplibre-gl@5, maplibre-gl-geocoder@1) meant a CDN could serve any future release
* to every partner's visitors, and with no integrity attribute nothing would notice a tampered file.
* Each entry is loaded with `integrity` + `crossorigin="anonymous"` (every host here answers
* Access-Control-Allow-Origin: *, measured), so the browser refuses a file whose bytes differ.
*
* TO BUMP A VERSION: change the url, recompute the hash, and run check:cdn-pins (it re-fetches every
* url and compares):
* curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
*
* Not here: the RTL text plugin, which the ENGINE fetches (setRTLTextPlugin), not a <script> tag, so
* integrity cannot be applied; it is pinned to an exact version in library/maps/rtl-text.js.
*/
export const CDN = {
mapboxGlJs: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js', integrity: 'sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ' },
mapboxGlCss: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css', integrity: 'sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV' },
// ⚠ MapLibre stays on 5.x FOR NOW, knowingly: GHSA-jrc7-96c5-q579 (an XSS bypass in DOM.sanitize, reached
// through a source's ATTRIBUTION string) affects every release <= 6.4.0, and 6.x is ESM-only (no UMD
// `maplibregl` global), so upgrading is a loader migration, not a version bump. Exposure meanwhile:
// the attribution strings rendered are those of the fixed basemap styles we configure and our own
// sources, none reader- or partner-supplied, and SRI pins these exact bytes. A partner who passes a
// style from an untrusted origin is exposed until the 6.x migration (tracked separately) lands.
maplibreGlJs: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js', integrity: 'sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp' },
maplibreGlCss: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css', integrity: 'sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK' },
mapboxGeocoderJs: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js', integrity: 'sha384-Lx9WK8XhZk57czwxt6B6nyaDHB5Ayrcrsu4Fw4bCp/Ot/QLOsUZ0xYqLOpOOSXZw' },
mapboxGeocoderCss: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', integrity: 'sha384-1KNhwJNCQTr0DCi/5/OnQcFytgycxTahxNZaqKbAtqmY8bUhu26qxXqvDGXXwplS' },
maplibreGeocoderJs: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.min.js', integrity: 'sha384-RGV5vfoJd7alk8HE+71IyVfu19ciH1FKA15+AuXs6OeBM2npXrmTPFqG23yMiHXi' },
maplibreGeocoderCss: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.css', integrity: 'sha384-UkDpPApjTpHJHpcJiSNAmbobh30W0/dmt0DoKm4V8CWYRYvQ/Jhd/ijFz0tFdVnH' },
// d3 for the PPLR maps and the Wall, which load it themselves (pplrData.js, wallD3.js) and keep it off
// window.d3; listed so check:cdn-pins can verify their pin.
d3: { url: 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js', integrity: 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i' },
}
/** Set integrity + crossorigin on a <script>/<link> for a registry entry (no-op for an unknown url). */
export function pinElement(el, url) {
const e = Object.values(CDN).find((x) => x.url === url)
if (!e) return el
el.integrity = e.integrity
el.crossOrigin = 'anonymous'
return el
}

View file

@ -6,6 +6,7 @@
* which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with * which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/. * pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
*/ */
import { CDN, pinElement } from '@map/constants/cdn.js'
let base = './assets' let base = './assets'
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') } export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
export function dataUrl(file) { return base + '/pplr/' + file } export function dataUrl(file) { return base + '/pplr/' + file }
@ -89,12 +90,9 @@ export function loadNames() {
// the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox // the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox
// script made this the only request to api.mapbox.com in the keyless (maplibre) gallery. // script made this the only request to api.mapbox.com in the keyless (maplibre) gallery.
export const GEOCODER = { export const GEOCODER = {
mapbox: { js: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js', // Exact versions + integrity, from the one registry (library/constants/cdn.js, CRITIC-5).
css: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', mapbox: { js: CDN.mapboxGeocoderJs.url, css: CDN.mapboxGeocoderCss.url, global: 'MapboxGeocoder' },
global: 'MapboxGeocoder' }, maplibre: { js: CDN.maplibreGeocoderJs.url, css: CDN.maplibreGeocoderCss.url, global: 'MaplibreGeocoder' },
maplibre: { js: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js',
css: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
global: 'MaplibreGeocoder' },
} }
const geocoderPromises = {} const geocoderPromises = {}
export function ensureGeocoder(provider = 'mapbox') { export function ensureGeocoder(provider = 'mapbox') {
@ -103,6 +101,7 @@ export function ensureGeocoder(provider = 'mapbox') {
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => { if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
const s = document.createElement('script') const s = document.createElement('script')
s.src = g.js s.src = g.js
pinElement(s, g.js)
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) } s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
document.head.appendChild(s) document.head.appendChild(s)
}) })