diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index adae3e236..e3e915d28 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -25,7 +25,7 @@ The scripts these commands are grounded in (from this branch's `package.json`): // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run gallery", +"build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:nested-token && bun run gallery", "preview": "bun x vite preview", "serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public", "cap:sync": "./node_modules/.bin/cap sync android", diff --git a/internal/check-nested-token-delivery.mjs b/internal/check-nested-token-delivery.mjs new file mode 100644 index 000000000..dc1e7c127 --- /dev/null +++ b/internal/check-nested-token-delivery.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * check-nested-token-delivery.mjs — every shipped leaf must carry its OWN map token. + * + * WHY THIS IS NOT COVERED BY THE EXISTING SMOKES. Both `smoke:embeds` and `verify:5-caps` open a + * page with `page.addInitScript(tk => { window.MAP_TOKEN = tk })` — they INJECT the token before + * the page runs. That is correct for what those checks test (does the element mount, does the + * capability hold), but it means the test SUPPLIES the very thing under test. A real user opening + * a shipped leaf has no Playwright injecting anything: the leaf must already carry a baked token. + * So no gate currently proves the delivery path itself. This one does, and it needs no browser. + * + * ★ WHY DELIVERY IS A SEPARATE PROPERTY FROM PAINT. A token can be present and the basemap still + * blank (this build box has no WebGL; Mapbox's URL restriction can also 403 the tiles). Those are + * CONSUMPTION failures. This gate asserts DELIVERY only — "does the token reach the page?" — which + * is the half that is our fault when it breaks, deterministic, and checkable offline. + * + * ★ WHY IT MATTERS FOR NESTING (the card this came from). A cap-5 SPA nests its children as + * iframes, and `window.MAP_TOKEN` does NOT cross an iframe boundary — each frame is its own + * window. A nested map therefore cannot inherit the parent's token; it works only because the + * CHILD LEAF carries its own baked copy. Proven live 2026-09-22: 13 of 13 nested frames in the + * cap-5 SPA reported a real pk. token, including a map nested two levels deep (SPA -> cap-4 + * composite -> cap-3 map). That proof was a one-off browser run; this makes it a standing gate. + * + * WHAT IT CHECKS: every leaf the manifest actually ships (profile leaves and instance leaves) + * contains a real `pk.` token. It reads the BUILT manifest rather than walking directories, so it + * checks the set that is published, not whatever happens to be on disk. + * + * ★ AND IT ENFORCES THE INVARIANT IN BOTH DIRECTIONS: a token belongs in leaf HTML and must NEVER + * appear in a `.js` bundle. A bundle is cached, SRI-pinned and reused across hosts; a token baked + * into one cannot be rotated without a rebuild, and it leaks into every embed. The leaf is the + * right place precisely because it is regenerated on every build. + * + * NEGATIVE-TESTED: stripping the token from one leaf makes this FAIL naming that leaf; planting a + * pk. string in a .js bundle makes this FAIL naming the bundle; restoring returns PASS. A manifest + * with no leaves FAILS (vacuous guard). + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +let treeName = 'doxa-maps' +try { + const cfg = JSON.parse(readFileSync(join(ROOT, 'vite.1040-maps-build-config.json'), 'utf8')) + if (cfg?.name) treeName = cfg.name +} catch { /* default */ } + +const TREE = join(ROOT, 'public', treeName) +const MANIFEST = join(TREE, 'manifest.json') + +if (!existsSync(MANIFEST)) { + console.log(`NESTED-TOKEN: SKIP — no built manifest at public/${treeName}/manifest.json.`) + console.log(' Run `bun run build` first. An absent tree is not evidence either way.') + process.exit(0) +} + +let manifest +try { + manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) +} catch (e) { + console.error(`NESTED-TOKEN: FAIL — manifest does not parse (${String(e.message).slice(0, 60)}).`) + process.exit(1) +} + +// A real Mapbox public token. Deliberately strict about the `pk.` prefix and a minimum body: an +// empty string or a placeholder like "REPLACE_ME" must NOT read as delivered. +const TOKEN_RE = /pk\.[A-Za-z0-9._-]{20,}/ + +// Collect every leaf URL the manifest publishes. Profiles carry iframeUrl; instances nest one +// level deeper and carry their own. Reading the manifest (not the filesystem) keeps this aligned +// with what is actually served. +const leaves = [] +for (const b of manifest.bundles || []) { + for (const p of b.profiles || []) { + if (p.iframeUrl) leaves.push({ bundle: b.bundle, profile: p.profile, url: p.iframeUrl }) + for (const inst of p.instances || []) { + if (inst.iframeUrl) leaves.push({ bundle: b.bundle, profile: `${p.profile}/${inst.id || inst.instance}`, url: inst.iframeUrl }) + } + } +} + +// Vacuous guard: "0 leaves" must never read as "0 problems". +const MIN_LEAVES = 20 +if (leaves.length < MIN_LEAVES) { + console.error(`NESTED-TOKEN: FAIL — manifest lists only ${leaves.length} leaf page(s) (expected >= ${MIN_LEAVES}).`) + console.error(' The manifest is truncated or the shape changed. Refusing to pass vacuously.') + process.exit(1) +} + +const missing = [] +let checked = 0 +for (const leaf of leaves) { + const rel = leaf.url.replace(/^\.\//, '') + const abs = join(TREE, rel) + if (!existsSync(abs)) { + missing.push({ ...leaf, why: 'leaf file does not exist in the built tree' }) + continue + } + checked++ + const html = readFileSync(abs, 'utf8') + if (!TOKEN_RE.test(html)) { + missing.push({ ...leaf, why: 'no pk. token baked into the leaf — a nested map here would get NOTHING' }) + } +} + +// The other direction: a token must never be compiled into a .js bundle. +const inBundles = [] +for (const b of manifest.bundles || []) { + const rel = (b.scriptUrl || '').replace(/^\.\//, '') + if (!rel) continue + const abs = join(TREE, rel) + if (!existsSync(abs)) continue + if (TOKEN_RE.test(readFileSync(abs, 'utf8'))) inBundles.push(b.bundle) +} + +console.log(`NESTED-TOKEN: ${checked} shipped leaf page(s) checked, ${(manifest.bundles || []).length} bundle(s) scanned.`) + +if (missing.length || inBundles.length) { + if (missing.length) { + console.error(`\nNESTED-TOKEN: FAIL — ${missing.length} leaf page(s) ship without a token:`) + for (const m of missing.slice(0, 20)) console.error(` ✗ ${m.bundle}/${m.profile}\n ${m.url}\n ${m.why}`) + if (missing.length > 20) console.error(` … +${missing.length - 20} more`) + console.error( + '\n window.MAP_TOKEN does NOT cross an iframe boundary — each frame is its own window. A cap-5\n' + + ' SPA nests its children as iframes, so a nested map cannot inherit the parent\'s token: it\n' + + ' works only because the CHILD leaf carries its own baked copy. A leaf without one is a map\n' + + ' that silently renders nothing for every user who opens it directly or through a shell.' + ) + } + if (inBundles.length) { + console.error(`\nNESTED-TOKEN: FAIL — ${inBundles.length} bundle(s) have a token COMPILED IN:`) + for (const b of inBundles) console.error(` ✗ ${b}`) + console.error( + '\n A token belongs in leaf HTML, never in a .js bundle. Bundles are cached, SRI-pinned and\n' + + ' reused across hosts, so a baked-in token cannot be rotated without a rebuild and leaks into\n' + + ' every embed. Leaves are regenerated on every build, which is exactly why they are the right\n' + + ' place. Check whether a source file reads the token at module scope instead of at mount.' + ) + } + process.exit(1) +} + +console.log('NESTED-TOKEN: PASS — every shipped leaf carries its own token, and no bundle does.') +console.log(' This is DELIVERY only. It does not prove a basemap paints: that needs a real GPU and') +console.log(' a token whose URL restrictions allow the serving host (see verify:map-paint).') diff --git a/package.json b/package.json index 2f6eea567..6c08728e7 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "check:public-hygiene": "node internal/public-hygiene-check.mjs", "check:label-joins": "node internal/check-label-joins.mjs", "check:dxlife": "node internal/check-dxlife-untouched.mjs", - "build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run gallery", + "build:unlocked": "bun run check:template-literal && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -73,7 +73,8 @@ "check:descriptions": "node internal/check-profile-descriptions.mjs # every shipped profile must say what it is (reads the BUILT manifest)", "check:no-abs-paths": "node internal/check-no-absolute-paths.mjs # no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)", "check:dep-names": "node internal/check-dependency-names.mjs # every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)", - "check:composite-srcs": "node internal/check-composite-srcs.mjs" + "check:composite-srcs": "node internal/check-composite-srcs.mjs", + "check:nested-token": "node internal/check-nested-token-delivery.mjs" }, "devDependencies": { "@capacitor/android": "^7.6.9",