The served sheet generator is integrity-pinned: a browser runs it only when its sha384 matches

Code loaded at print time executes on partner pages, so it is pinned like every embed. The bundle
carries the generator's sha384 (library/packet/poster-render.integrity.js, written by
`bun run pin:poster-render`); posterSheets fetches <assets>/lib/poster-render.js as TEXT, hashes it
with WebCrypto, and on a match imports the verified text from a blob: URL — never the network URL a
second time. A mismatch refuses to run, with a message naming the URL and both hashes; the print falls
back to what it can do without the generator.

The page base is captured when posterSheets evaluates, and every URL it resolves (the generator, the
boundaries, the places) resolves against it — not the bundle's location, and not a URL a single-page
host changed after load.

check:poster-render-pinned (gates:pre), two-sided: the pin equals the sha384 of the source and of the
served copy (a stale pin names the command — proven by an unpinned edit); a TAMPERED copy is refused
and its planted side effect never runs; the genuine copy loads and draws. check:poster-browser-equiv
still passes byte-identical through the pinned path. country-posters 1,472,734 bytes (ceiling
1,500,000), my-upg-100-list 1,232,508, both carrying the pin; verify-bundles-shipped PASS; gates:pre 74/74.
This commit is contained in:
Doxa Maps 2026-09-29 06:22:09 +00:00
parent 7987f100c1
commit 0be5f07ab6
5 changed files with 127 additions and 10 deletions

View file

@ -0,0 +1,56 @@
#!/usr/bin/env bun
/**
* check-poster-render-pinned — the map-sheet generator a browser loads at print time is the one this repo
* pinned, and anything else is refused BEFORE it runs.
*
* The generator is served (assets/lib/poster-render.js) and imported at print time rather than bundled.
* Code loaded that way executes on partner pages, so it is integrity-pinned like every embed: the bundle
* carries its sha384 (library/packet/poster-render.integrity.js) and posterSheets runs it only on a match.
*
* Two-sided:
* · the pin equals the sha384 of the source AND of the served copy (a stale pin names the fix);
* · a TAMPERED copy is refused, and its code never executes (a planted side effect stays unset);
* · the GENUINE copy loads and draws a real sheet.
*/
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const REPO = new URL('..', import.meta.url).pathname
const pinOf = (t) => `sha384-${createHash('sha384').update(t, 'utf8').digest('base64')}`
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} ${m}`); if (!c) fails++ }
const source = readFileSync(join(REPO, 'library/packet/poster-render.js'), 'utf8')
const served = readFileSync(join(REPO, 'public/dx-maps/assets/lib/poster-render.js'), 'utf8')
const { POSTER_RENDER_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
ok(POSTER_RENDER_SHA384 === pinOf(source),
`the pin matches library/packet/poster-render.js${POSTER_RENDER_SHA384 === pinOf(source) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`)
ok(pinOf(served) === pinOf(source), 'the served copy (assets/lib/poster-render.js) is the pinned file')
const { generateSheet, countryZone } = await import(join(REPO, 'library/packet/posterSheets.js'))
const zone = countryZone({ id: 'country-tcd', iso3: 'TCD', label: 'Chad', count: 1 })
const pins = [{ name: 'Probe', lat: 15, lng: 19, slug: 'probe', country_code: 'TCD', population: 1 }]
// TAMPERED: the pinned file plus one line that would run on import.
const tmp = mkdtempSync(join(tmpdir(), 'poster-pin-'))
try {
mkdirSync(join(tmp, 'lib'), { recursive: true })
writeFileSync(join(tmp, 'lib/poster-render.js'), `globalThis.__POSTER_TAMPERED_RAN__ = true;\n${source}`)
let refused = null
try { await generateSheet({ zone, pins, format: '3x3', seriesId: 'country', assetsBase: pathToFileURL(tmp).href }) }
catch (e) { refused = String(e?.message || e) }
ok(!!refused && /integrity/.test(refused), `a tampered generator is refused${refused ? ` ("${refused.slice(0, 70)}…")` : ' — IT WAS NOT'}`)
ok(globalThis.__POSTER_TAMPERED_RAN__ !== true, 'and its code never executed')
} finally { rmSync(tmp, { recursive: true, force: true }) }
// GENUINE: the served copy loads and draws.
const adm0 = JSON.parse(readFileSync(join(REPO, 'public/dx-maps/assets/boundaries/world-adm0.geojson'), 'utf8'))
const html = await generateSheet({ zone, pins, format: '3x3', seriesId: 'country', adm0,
assetsBase: pathToFileURL(join(REPO, 'public/dx-maps/assets')).href, date: '2026-09-29' })
ok(typeof html === 'string' && html.length > 10000 && /<svg\b/.test(html), `the pinned generator loads and draws a sheet (${html.length} bytes)`)
if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) }
console.log('POSTER-RENDER-PINNED: PASS — the served generator is the pinned one, a tampered copy is refused unrun, and the genuine copy draws')

View file

@ -0,0 +1,5 @@
// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand.
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
// print time is integrity-pinned like every embed, because it executes on partner pages.
export const POSTER_RENDER_SHA384 = 'sha384-8uzpgX1i2IEQNEh7WIxVxanacbBvX2r11/VeqSxYHzInWdV5XlLDoMCONlYI2pFA'

View file

@ -15,17 +15,44 @@
* const html = await generateSheet({ zone, pins, zoneFC, format: 'packet', seriesId: 'upg100',
* ISO3_NAME, series: { totalGroups, totalLists, index } })
*/
// ★ THE GENERATOR IS IMPORTED BY URL, NOT BUNDLED. poster-render.js is served verbatim at
// <assets>/lib/poster-render.js (generate-tree), and loaded at the FIRST print: a single-file build
import { POSTER_RENDER_SHA384 } from './poster-render.integrity.js'
// ★ THE GENERATOR IS SERVED, NOT BUNDLED, AND IT IS INTEGRITY-PINNED. poster-render.js is served verbatim
// at <assets>/lib/poster-render.js (generate-tree) and loaded at the FIRST print: a single-file build
// inlines any import, so bundling it put ~65 KB into every map that can print a sheet (country-posters
// crossed the 1.5 MB ceiling). The URL is resolved against the PAGE — a relative dynamic import would
// resolve against the bundle's own location instead. @vite-ignore keeps the bundler from inlining it.
// crossed the 1.5 MB ceiling). Code fetched at print time EXECUTES on partner pages, so it is pinned like
// every embed: its sha384 is baked into this bundle (poster-render.integrity.js), the text is fetched and
// hashed, and only a match is run — imported from a blob: URL of the VERIFIED text, never from the network
// URL a second time. A mismatch refuses to run and says so; the print falls back to what it can do without.
//
// ★ THE PAGE BASE IS CAPTURED WHEN THIS MODULE EVALUATES, not at print time: a single-page host that
// changes its URL between load and click would otherwise resolve the asset base against the wrong page,
// and a relative dynamic import would resolve against the BUNDLE's location instead of the page's.
const PAGE_BASE = globalThis.document?.baseURI || undefined
const abs = (u) => new URL(u, PAGE_BASE).href
async function sha384(text) {
const d = new Uint8Array(await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text)))
let bin = ''
for (const b of d) bin += String.fromCharCode(b)
return `sha384-${btoa(bin)}`
}
const generatorCache = new Map()
function loadGenerator(assetsBase) {
if (!assetsBase) return Promise.reject(new Error('no asset base to load the sheet generator from'))
const url = new URL(`${assetsBase}/lib/poster-render.js`, globalThis.document?.baseURI || undefined).href
const url = abs(`${assetsBase}/lib/poster-render.js`)
if (!generatorCache.has(url)) {
const p = import(/* @vite-ignore */ url).then((m) => m.createPosterGenerator)
const p = (async () => {
const res = await fetch(url, { cache: 'force-cache' })
if (!res.ok) throw new Error(`the sheet generator is not served (${res.status}) at ${url}`)
const text = await res.text()
const got = await sha384(text)
if (got !== POSTER_RENDER_SHA384) {
throw new Error(`the sheet generator at ${url} failed its integrity check (expected ${POSTER_RENDER_SHA384}, got ${got}) — refusing to run it`)
}
const blobUrl = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }))
try { return (await import(/* @vite-ignore */ blobUrl)).createPosterGenerator }
finally { URL.revokeObjectURL(blobUrl) }
})()
p.catch(() => generatorCache.delete(url))
generatorCache.set(url, p)
}
@ -81,7 +108,7 @@ export async function generateSheet({
const jsonCache = new Map()
function getJSON(url) {
if (!jsonCache.has(url)) {
const p = fetch(url, { cache: 'force-cache' }).then((r) => {
const p = fetch(abs(url), { cache: 'force-cache' }).then((r) => {
if (!r.ok) throw new Error(`${url.split('/').pop()} not found (${r.status})`)
return r.json()
})

View file

@ -0,0 +1,25 @@
#!/usr/bin/env node
// pin-poster-render.mjs — write library/packet/poster-render.integrity.js, the sha384 the browser checks
// before it runs the served map-sheet generator. Run it whenever library/packet/poster-render.js changes;
// check:poster-render-pinned fails (and names this command) until the pin matches the file again.
//
// node library/scripts/posters/pin-poster-render.mjs
import { readFileSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { join, dirname } from 'node:path'
const LIB = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'packet')
export const pinOf = (text) => `sha384-${createHash('sha384').update(text, 'utf8').digest('base64')}`
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isMain) {
const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8'))
writeFileSync(join(LIB, 'poster-render.integrity.js'), `// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand.
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
// print time is integrity-pinned like every embed, because it executes on partner pages.
export const POSTER_RENDER_SHA384 = '${pin}'
`)
console.log(`[pin-poster-render] ${pin}`)
}

View file

@ -324,7 +324,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -371,7 +371,9 @@
"verify:interop:self-test:camera-freeze": "node internal/acceptance/interop-harness.mjs --self-test --self-test-mode=camera-freeze",
"verify:map-paint": "node internal/verify-map-paint.mjs",
"check:people-group-records": "bun internal/check-people-group-records.mjs",
"check:poster-browser-equiv": "bun internal/check-poster-browser-equiv.mjs"
"check:poster-browser-equiv": "bun internal/check-poster-browser-equiv.mjs",
"check:poster-render-pinned": "bun internal/check-poster-render-pinned.mjs",
"pin:poster-render": "node library/scripts/posters/pin-poster-render.mjs"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -602,7 +604,9 @@
"check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (measured at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs.",
"check:sentence-slots": "a translated SENTENCE that carries values stays ONE key, through library/composables/useSentence.js. Four properties that all fail silently: a translator can REORDER the clauses (the slots follow the message, not the call); a missing slot leaves its placeholder VISIBLE rather than rendering null or an empty gap, while 0 and '' are real values; slotted markup (a bold count, a dir=ltr island) goes in as a VALUE and is never translated around; and English renders as it did before the migration. Exists because the pattern it replaces built sentences by concatenating translated fragments around untranslated text — 'languages have', 'people.' — which in Arabic gives Arabic words in English clause order: grammatically broken, and worse than leaving it English because it looks translated. Sentinels are private-use (U+E000+n) so nothing in the i18n pipeline may rewrite them, and any sentinel already in a message is stripped before ours are placed — without that a stray swallowed a slot and the arm still passed, which is how this gate found its own weakness.",
"check:people-group-records": "the records half of the print standard selects EXACTLY the set it is asked for, two-sided: a country selector returns that country and not another, an unknown selector returns nothing rather than every group on earth, one fetch per language serves every item, and a failed fetch is not cached so the next click retries.",
"check:poster-browser-equiv": "a map sheet GENERATED IN THE BROWSER (library/packet/posterSheets.js, the print standard: generate at the click) is byte-identical to the one the Node generator writes, given the same records: it runs the real generate.mjs for one UPG-100 zone (the map-only packet sheet, an India zone so the ADM1 path runs) and one country, draws both through the browser feed (the SERVED boundaries, data/zone-sheet.js, the library name table) and compares bytes. The drawing code is one factory (poster-render.js); this guards the FEED, which is what can still drift. NOT MEASURED without the generator's offline records cache."
"check:poster-browser-equiv": "a map sheet GENERATED IN THE BROWSER (library/packet/posterSheets.js, the print standard: generate at the click) is byte-identical to the one the Node generator writes, given the same records: it runs the real generate.mjs for one UPG-100 zone (the map-only packet sheet, an India zone so the ADM1 path runs) and one country, draws both through the browser feed (the SERVED boundaries, data/zone-sheet.js, the library name table) and compares bytes. The drawing code is one factory (poster-render.js); this guards the FEED, which is what can still drift. NOT MEASURED without the generator's offline records cache.",
"check:poster-render-pinned": "the map-sheet generator a browser imports at print time (served at assets/lib/poster-render.js, not bundled) is integrity-pinned like every embed, because it executes on partner pages: the pin in library/packet/poster-render.integrity.js equals the sha384 of the source and of the served copy (a stale pin names `bun run pin:poster-render`), a TAMPERED copy is refused and its code never runs (a planted side effect stays unset), and the genuine copy loads and draws.",
"pin:poster-render": "rewrite library/packet/poster-render.integrity.js from library/packet/poster-render.js — run after any change to the generator; check:poster-render-pinned fails until it is."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",