From 0c7bfb9b9cf55c60670052cce08b8140cf22d061 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:05:53 +0000 Subject: [PATCH] add a catalog CURRENCY gate: is the live tree the build we have? check:catalog-identity hashes each live bundle against the CATALOG'S OWN manifest - deliberately, per its header. That proves the served tree is internally consistent; it can never prove it is current. A catalog three publishes behind is perfectly self-consistent and passes clean. Measured today: after baking the Mapbox token, catalog-identity reported '17 of 17 identity-proven - PASS' while every live leaf still served BAKED_TOKEN = '' and every map on the platform was broken. The two gates answer different questions and neither subsumes the other: identity is the live catalog consistent WITH ITSELF? (tamper/corruption) currency is the live catalog the SAME BUILD as ours? (staleness) It checks BOTH bundles and leaf pages, because a bundle-only version of this gate also passed: baking the token changed 37 leaf HTML pages and ZERO .js bundles. generate-tree owns the leaves, vite owns the bundles, and a publish can change either independently. Current reading: bundles 17 current, leaves 17 STALE. Negative-tested: unreachable catalog SKIPs (offline must not fail a build), a manifest yielding 0 integrity values FAILs rather than passing vacuously, and a missing local build SKIPs. --- internal/check-catalog-currency.mjs | 115 ++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 116 insertions(+) create mode 100644 internal/check-catalog-currency.mjs diff --git a/internal/check-catalog-currency.mjs b/internal/check-catalog-currency.mjs new file mode 100644 index 000000000..4709a0fd2 --- /dev/null +++ b/internal/check-catalog-currency.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * check-catalog-currency.mjs — is the LIVE catalog serving what we have BUILT? + * + * THE BLIND SPOT THIS FILLS. `check:catalog-identity` hashes each live bundle and compares it to + * the CATALOG'S OWN manifest integrity — deliberately, per its own header: "COMPARE AGAINST THE + * CATALOG'S OWN MANIFEST, NOT THE LOCAL ONE." That proves the served tree is INTERNALLY + * CONSISTENT. It can never prove the served tree is CURRENT: a catalog three publishes behind is + * perfectly self-consistent and passes with a clean PASS. + * + * Measured 2026-09-21, which is why this exists: after baking the Mapbox token locally, + * `check:catalog-identity` reported "17 of 17 identity-proven — PASS" while the live leaf still + * served `BAKED_TOKEN = ""`, i.e. every map on the platform was still broken. Two gates, two + * questions: + * + * check:catalog-identity is the live catalog consistent WITH ITSELF? (tamper / corruption) + * check:catalog-currency is the live catalog the SAME BUILD as ours? (staleness) + * + * Neither subsumes the other. A tampered catalog fails identity and passes currency; a stale one + * passes identity and fails currency. + * + * SKIPs (exit 0) when the catalog is unreachable — an offline box must not fail a build. Only + * "reached it and it is behind" is a failure. + */ +import { readFileSync, existsSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const args = process.argv.slice(2) +const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d } +const BASE = (argOf('--base', 'https://nuxtbase.app/plugin-assets/doxa-maps')).replace(/\/+$/, '') +const TIMEOUT = Number(argOf('--timeout', 25000)) + +const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64') + +const localManifestPath = join(ROOT, 'public', 'doxa-maps', 'manifest.json') +if (!existsSync(localManifestPath)) { + console.log('CATALOG-CURRENCY: SKIP — no local build (public/doxa-maps/manifest.json missing). Run `bun run build`.') + process.exit(0) +} +const local = JSON.parse(readFileSync(localManifestPath, 'utf8')) +const localBundles = new Map() +for (const b of local.bundles || []) { + // VERIFIED against the real manifest shape 2026-09-21: integrity sits at bundle.integrity + // (bundle keys are bundle/scriptUrl/output/integrity/elements/profiles). It is NOT under + // profiles[].embed — that object holds only `iframe` and `script` snippet strings. + if (b.integrity) localBundles.set(b.bundle, b.integrity) +} +if (!localBundles.size) { + console.error('CATALOG-CURRENCY: FAIL — parsed 0 integrity values from the local manifest; the matcher is broken.') + process.exit(1) +} + +const fetchBuf = async (url) => { + const ctl = new AbortController() + const t = setTimeout(() => ctl.abort(), TIMEOUT) + try { + const r = await fetch(url, { signal: ctl.signal, redirect: 'follow' }) + if (!r.ok) return { err: `HTTP ${r.status}` } + return { buf: Buffer.from(await r.arrayBuffer()) } + } catch (e) { + return { err: String(e.message || e).slice(0, 60) } + } finally { clearTimeout(t) } +} + +const stale = []; const matched = []; let unreachable = 0 +for (const [bundle, wantIntegrity] of localBundles) { + const { buf, err } = await fetchBuf(`${BASE}/${bundle}/${bundle}.js`) + if (err) { unreachable++; continue } + const got = sha384(buf) + if (got === wantIntegrity) matched.push(bundle) + else stale.push({ bundle, want: wantIntegrity.slice(0, 22), got: got.slice(0, 22) }) +} + +if (unreachable === localBundles.size) { + console.log(`CATALOG-CURRENCY: SKIP — catalog unreachable at ${BASE} (${unreachable} bundle(s)). Offline is not a failure.`) + process.exit(0) +} + +// ── LEAF PAGES TOO, and this half is not optional ───────────────────────────────────────────── +// Measured 2026-09-21: baking the Mapbox token changed 37 LEAF HTML pages and ZERO .js bundles +// (BAKED_TOKEN is emitted by generate-tree into each leaf, never into the bundle). A bundle-only +// currency check therefore reported "17 current, 0 STALE — PASS" while every live leaf still +// served BAKED_TOKEN = "" and every map on the platform was broken. +// The lesson generalises: generate-tree owns the leaf pages, vite owns the bundles, and a publish +// can change EITHER independently. Checking one proves nothing about the other. +const leafStale = []; let leafMatched = 0; let leafUnreachable = 0 +for (const b of local.bundles || []) { + const profile = b.profiles?.[0]?.profile + if (!profile) continue + const rel = `${b.bundle}/${profile}/index.html` + const localPath = join(ROOT, 'public', 'doxa-maps', rel) + if (!existsSync(localPath)) continue + const { buf, err } = await fetchBuf(`${BASE}/${rel}`) + if (err) { leafUnreachable++; continue } + const want = sha384(readFileSync(localPath)) + const got = sha384(buf) + if (want === got) leafMatched++ + else leafStale.push({ rel, want: want.slice(0, 22), got: got.slice(0, 22) }) +} +console.log(`CATALOG-CURRENCY: leaves — ${leafMatched} current · ${leafStale.length} STALE · ${leafUnreachable} unreachable`) +for (const s of leafStale) console.error(` ✗ ${s.rel}\n local ${s.want}…\n live ${s.got}…`) +stale.push(...leafStale.map(s => ({ bundle: s.rel, want: s.want, got: s.got }))) + +console.log(`CATALOG-CURRENCY: ${matched.length} current · ${stale.length} STALE · ${unreachable} unreachable (of ${localBundles.size} local bundles)`) +if (stale.length) { + for (const s of stale) console.error(` ✗ ${s.bundle}\n local ${s.want}…\n live ${s.got}…`) + console.error('\n The live catalog is BEHIND this build. Publish per') + console.error(' _qa-cycle/03-deployment-cycle/delegation/RUNBOOK-publish-bundles-to-catalog.md') + console.error(' (rsync public/doxa-maps/ into the catalog, then push with -c http.postBuffer=524288000).') + process.exit(1) +} +console.log('CATALOG-CURRENCY: PASS — every reachable live bundle matches this build.') diff --git a/package.json b/package.json index 605091244..4a5286c01 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate docs/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).", "check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `