Serve the packet text at print time instead of bundling all 11 languages

A single-file build inlines every import, dynamic ones included, so
packet-strings.json (~115 KB minified: every language's prayer points,
captions and headings) rode in every bundle that can print a packet, list
or card deck. With the wall's print messages translated, country-posters
crossed the 1.5 MB ceiling (1,502,226 bytes).

The table is now served at <assets>/lib/packet-strings.json and fetched at
the first print by library/packet/packetStrings.js, the same move as the
sheet generator and ne-places.json:

- Pinned like them: PACKET_STRINGS_SHA384 in poster-render.integrity.js
  (pin-poster-render.mjs); the text is hashed before it is parsed, and an
  unmatched table is not used. These strings are laid into the print
  document, so an unverified copy never reaches it.
- English is bundled as the fallback (a named import of the JSON, so only
  the en block ships): with no asset base, on file://, on a network error or
  an integrity mismatch the print comes out in English and the console says
  why. A print never fails for want of this file.
- generate-tree copies it into the pool AND the served allowlist; verified
  by regenerating the tree (the served copy is byte-identical to source).
- check:poster-render-pinned, two-sided: the pin matches source and served
  copy, the genuine copy loads all 11 languages, a tampered copy falls back
  to English (mutation-tested: disabling the hash check fails it), and an
  unserved one falls back rather than failing.
- sha384 is one shared helper (library/packet/sha384.js).

Sizes: country-posters 1,378,463 (was 1,484,053), my-upg-100-list 1,143,456
(was 1,243,509), regions-dashboard 973,802 (was 1,079,448).
This commit is contained in:
Doxa Maps 2026-09-29 08:18:55 +00:00
parent 03839179b7
commit a3e58ac7ca
10 changed files with 2847 additions and 18 deletions

View file

@ -12,7 +12,7 @@
* · 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 { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -98,5 +98,36 @@ ok(typeof html === 'string' && html.length > 10000 && /<svg\b/.test(html), `the
ok(a === b && a.length > 10000, `run as a classic script it draws the module's sheet byte for byte (${a.length} bytes)`)
}
// THE PACKET TEXT (library/packet/packetStrings.js), served at lib/packet-strings.json and pinned. Two-sided:
// the GENUINE served copy loads every language, and a TAMPERED one is refused — the print falls back to the
// bundled English block, so the forged text never reaches a print document.
{
const { PACKET_STRINGS_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
const stringsSrc = readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8')
const servedStrings = join(REPO, 'public/dx-maps/assets/lib/packet-strings.json')
ok(PACKET_STRINGS_SHA384 === pinOf(stringsSrc),
`the packet-strings pin matches its source${PACKET_STRINGS_SHA384 === pinOf(stringsSrc) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`)
ok(existsSync(servedStrings) && pinOf(readFileSync(servedStrings, 'utf8')) === pinOf(stringsSrc),
'the served copy (assets/lib/packet-strings.json) is the pinned file')
const { loadPacketStrings } = await import(join(REPO, 'library/packet/packetStrings.js'))
const genuine = await loadPacketStrings(pathToFileURL(join(REPO, 'public/dx-maps/assets')).href)
const langs = Object.keys(genuine).filter((k) => k !== '_meta')
ok(langs.length === 11 && genuine.zh?.zone_affinity_A017 === JSON.parse(stringsSrc).zh.zone_affinity_A017,
`the genuine served copy loads all ${langs.length} languages`)
const tmp3 = mkdtempSync(join(tmpdir(), 'strings-pin-'))
const warn = console.warn; console.warn = () => {}
try {
mkdirSync(join(tmp3, 'lib'), { recursive: true })
const forged = JSON.parse(stringsSrc); forged.zh.zone_affinity_A017 = 'FORGED'
writeFileSync(join(tmp3, 'lib/packet-strings.json'), JSON.stringify(forged, null, 2) + '\n')
const tampered = await loadPacketStrings(pathToFileURL(tmp3).href)
ok(!JSON.stringify(tampered).includes('FORGED') && Object.keys(tampered).join() === 'en'
&& tampered.en.zone_affinity_A017 === JSON.parse(stringsSrc).en.zone_affinity_A017,
'a tampered packet-strings table is refused — the print falls back to the bundled English block')
const absent = await loadPacketStrings(pathToFileURL(join(tmp3, 'nowhere')).href)
ok(Object.keys(absent).join() === 'en', 'an unserved table falls back to English rather than failing the print')
} finally { console.warn = warn; rmSync(tmp3, { recursive: true, force: true }) }
}
if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) }
console.log('POSTER-RENDER-PINNED: PASS — the served generator and places table are the pinned ones, a tampered generator is refused unrun, a tampered table is not used, and the genuine copies draw')

View file

@ -1722,6 +1722,10 @@ function main() {
// <script integrity>), written by the same transform the pin is computed from.
writeFileSync(join(libDir, 'poster-render.global.js'),
globalVariant(readFileSync(resolve(__dirname, '..', 'library', 'packet', 'poster-render.js'), 'utf8')))
// …and the packet/list/card TEXT in all 11 languages (~115 KB), for the same reason: every bundle that can
// print inlined the whole table. library/packet/packetStrings.js fetches this copy at print time, checks
// it against PACKET_STRINGS_SHA384, and falls back to the bundled English block if it cannot.
cpSync(resolve(__dirname, '..', 'library', 'scripts', 'posters', 'packet-strings.json'), join(libDir, 'packet-strings.json'))
// DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization):
// source-of-truth folders that used to sit ONLY inside public/ now live in
@ -1794,6 +1798,7 @@ function main() {
'boundaries/ne-places.json',
'lib/poster-render.js',
'lib/poster-render.global.js', // the classic-script copy, same generator (see the pool stage)
'lib/packet-strings.json', // the packet/list/card text, fetched at print time (packetStrings.js)
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',

View file

@ -28,6 +28,8 @@ import { ref } from 'vue';
import { openPrintFrame } from './printInPlace.js';
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
import { keyedError, errorText } from '../i18n/keyedError.js';
// The packet/card text, fetched from the served tree at print time (English bundled as the fallback).
import { loadPacketStrings } from '../packet/packetStrings.js';
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@ -48,7 +50,8 @@ const esc = (s) => String(s ?? '')
* label (item) => string
* stock () => string — card stock id (see card-render.js STOCK).
* state ref — the host's HUD state ref, when it renders its own HUD.
* strings () => Promise<object> — the packet/card catalog; defaults to packet-strings.json.
* strings () => Promise<object> — the packet/card catalog; defaults to the served, pinned
* packet-strings.json (packet/packetStrings.js, English fallback).
* sheet async (item, format) => html — a map SHEET the host generates now (posterSheets.js
* generateSheet): 'letter' for the poster print (a vector page),
* 'packet' for the packet's page 1. Tried FIRST by both.
@ -254,7 +257,7 @@ export function usePosterPrints(opts = {}) {
const data = await rows(item);
const [{ createCardRenderer }, strings] = await Promise.all([
import('../packet/card-render.js'),
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
opts.strings ? opts.strings() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
]);
set(item, kind, 'render', tr('wall.hud.layingOutCards', 'laying out {n} cards', { n: data.length }, data.length), 70);
const R = createCardRenderer({ strings, lang: lang(), stock: stock() });
@ -336,7 +339,7 @@ export function usePosterPrints(opts = {}) {
import('../packet/packet-render.js'),
import('../packet/packetSets.js'),
import('../packet/evangelicalShare.js'),
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
opts.strings ? opts.strings() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
opts.christian ? opts.christian() : Promise.resolve(null),
opts.christian ? Promise.resolve(null)
: import('../../data/people-groups/evangelical-by-slug.json').then((m) => m.default || m).catch(() => null),

View file

@ -0,0 +1,49 @@
/**
* packetStrings — the text of every printed packet, list and card deck, in all 11 languages, FETCHED at print
* time from the served tree (<assets>/lib/packet-strings.json) instead of bundled.
*
* WHY. A single-file build inlines every import, dynamic ones included, so the whole table (~115 KB
* minified: every language's prayer points, captions and headings) rode in every bundle that can print —
* country-posters crossed the 1.5 MB ceiling once its wall's print messages were translated. A reader needs
* the table only when they print. Same move as the sheet generator and ne-places.json (posterSheets.js).
*
* PINNED like them (PACKET_STRINGS_SHA384): the text is hashed before it is parsed, and a table that is not
* the pinned one is not used. These strings are laid into the print document, so an unverified copy must
* never reach it.
*
* ★ ENGLISH IS BUNDLED AS THE FALLBACK, so a print never fails for want of this file: without an asset base
* (a host that sets none), on file:// where a page cannot fetch, on a network error, or on an integrity
* mismatch, the packet prints in English and the console says why. `{ en }` is a named import of the JSON, so
* only the English block is bundled; the other ten languages stay on the server.
*/
import { en as EN } from '../scripts/posters/packet-strings.json'
import { PACKET_STRINGS_SHA384 } from './poster-render.integrity.js'
import { sha384 } from './sha384.js'
// Captured when the module evaluates, as in posterSheets.js: a relative asset base resolves against the page
// that loaded the map, even if a single-page host changes its URL before the click.
const PAGE_BASE = globalThis.document?.baseURI || undefined
const ENGLISH_ONLY = Object.freeze({ en: EN })
const cache = new Map()
/** The full table ({ en, es, …, zh }), or `{ en }` when the served copy cannot be used. Never rejects. */
export function loadPacketStrings(assetsBase) {
if (!assetsBase) return Promise.resolve(ENGLISH_ONLY)
let url
try { url = new URL(`${assetsBase}/lib/packet-strings.json`, PAGE_BASE).href } catch { return Promise.resolve(ENGLISH_ONLY) }
if (!cache.has(url)) {
const p = (async () => {
const res = await fetch(url, { cache: 'force-cache' })
if (!res.ok) throw new Error(`not served (${res.status})`)
const text = await res.text()
if ((await sha384(text)) !== PACKET_STRINGS_SHA384) throw new Error('failed its integrity check')
return JSON.parse(text)
})().catch((e) => {
console.warn(`[packetStrings] ${url}: ${e?.message || e} — printing in English`)
cache.delete(url) // a later print tries again rather than inheriting one failure
return ENGLISH_ONLY
})
cache.set(url, p)
}
return cache.get(url)
}

View file

@ -9,3 +9,7 @@ export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8r
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-gRuzl6j8EMIN3bQXFZH3CSXKWx7w8LCf45PlTgXorFrFs/9jIKX3Ic3D/iCOpWwU'
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
// when it matches; otherwise the print falls back to the bundled English block.
export const PACKET_STRINGS_SHA384 = 'sha384-XtvN8GulVpQPnSgA3Qr2HHbUC9ivL3OsuhhO+jDhBz6/J1PTlzOx8uTPVQjI8bjR'

View file

@ -16,6 +16,7 @@
* ISO3_NAME, series: { totalGroups, totalLists, index } })
*/
import { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } from './poster-render.integrity.js'
import { sha384 } from './sha384.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
@ -30,12 +31,6 @@ import { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } f
// 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)}`
}
// The classic-script path: the browser checks POSTER_RENDER_GLOBAL_SHA384 itself and refuses to run a
// file that does not match (onerror), so nothing unverified executes here either.
function loadClassic(url) {

9
library/packet/sha384.js Normal file
View file

@ -0,0 +1,9 @@
// The Subresource-Integrity form of a text's SHA-384 ("sha384-<base64>"), via WebCrypto, so a file fetched at
// print time can be compared with the pin baked into the bundle (poster-render.integrity.js) before it is used.
// The same value pin-poster-render.mjs computes with node:crypto.
export 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)}`
}

View file

@ -29,6 +29,7 @@ if (isMain) {
const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8'))
const places = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'data', 'ne-places.json'), 'utf8'))
const classic = pinOf(globalVariant(readFileSync(join(LIB, 'poster-render.js'), 'utf8')))
const strings = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'packet-strings.json'), '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
@ -40,6 +41,10 @@ export const NE_PLACES_SHA384 = '${places}'
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
export const POSTER_RENDER_GLOBAL_SHA384 = '${classic}'
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
// when it matches; otherwise the print falls back to the bundled English block.
export const PACKET_STRINGS_SHA384 = '${strings}'
`)
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}\n[pin-poster-render] classic ${classic}`)
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}\n[pin-poster-render] classic ${classic}\n[pin-poster-render] packet-strings ${strings}`)
}

View file

@ -112,6 +112,8 @@ import { countryName } from '@map/utils/countryName.js'
// Errors a reader sees carry their catalogue key; errorText() shows them in the reader's language.
// Strings only this map shows live in their own area, upgMap.json, so no other bundle carries them.
import { keyedError, errorText } from '@map/i18n/keyedError.js'
// The packet's text table, served and integrity-pinned rather than bundled (library/packet/packetStrings.js).
import { loadPacketStrings } from '@map/packet/packetStrings.js'
import { resolveAllZones } from './data/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
// holds the region-inherited tapestry (purple ME, red Africa, green
@ -1507,7 +1509,7 @@ async function localizedZone(zone) {
const [{ zoneTitle }, { countryName }, strings] = await Promise.all([
import('./data/zone-title.js'),
import('@map/utils/countryName.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
loadPacketStrings(resolveAssetsBase(profileConfig.value)),
]);
// the packet's own strings for this language, English per key where a block lacks one — the same
// fallback the renderer applies (an affinity-bloc zone such as Deaf is named from them)
@ -1689,13 +1691,11 @@ function selectedLists() {
// still leave the caller to merge.
// The packet's own string block for the reader's locale, per-KEY fallback to English exactly as
// packet.mjs and packet-render do it — a half-translated block must not blank a key that en has.
let _packetStrings = null;
// Not memoised here: loadPacketStrings caches a SUCCESS itself, and memoising the merged block would pin an
// English-only fallback (a transient fetch failure) for the rest of the session.
async function packetStrings() {
if (!_packetStrings) {
const all = await import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m);
_packetStrings = { ...(all.en || {}), ...(all[packetLang()] || {}) };
}
return _packetStrings;
const all = await loadPacketStrings(resolveAssetsBase(profileConfig.value));
return { ...(all.en || {}), ...(all[packetLang()] || {}) };
}
/** `{a}` / `{b}` / `{n}` interpolation; null when the pattern is missing so the caller can default. */
function fillJoin(pattern, params) {

File diff suppressed because it is too large Load diff