doxa-maps/internal/check-catalog-identity.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

122 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* check-catalog-identity.mjs — prove the LIVE server is serving the CATALOG's exact bytes.
*
* THE QUESTION THIS ANSWERS, AND WHY THE OBVIOUS CHECK CANNOT ANSWER IT
* Card 1 (IDEA-MAPS-BUNDLER-PUBLIC-FORGEJO-PIPELINE) asks that ONE public Forgejo repo be the live
* catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null
* -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered:
* · https://nuxtbase.app/app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
* SIGN-IN PAGE. An http-status check reads it as healthy.
* · a stale CDN copy also returns 200, with the wrong bytes.
*
* ★ SRI IS THE RIGHT INSTRUMENT. Every profile in manifest.json already carries an
* `integrity: "sha384-…"` computed by the build over its own bundle. So hashing the LIVE response
* and comparing against that field is a cryptographic identity proof, using a value the build
* produced for an unrelated reason. No new source of truth, no new thing to keep in sync.
*
* ★ COMPARE AGAINST THE CATALOG'S OWN MANIFEST, NOT THE LOCAL ONE. Reading the local build's
* manifest would prove "live matches what I just built here", which is a different and weaker claim
* — it passes even when the publish never happened. Fetching the manifest from the live server and
* using ITS integrity values proves the served tree is INTERNALLY consistent; pass --catalog to
* additionally require that it matches a cloned catalog checkout.
*
* MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what nuxtbase serves.
*
* USAGE
* bun run check:catalog-identity # live public door vs its own manifest
* CATALOG=/tmp/cat/app/doxa-maps bun run check:catalog-identity # also require catalog match
* Network-dependent, so it is NOT in build:unlocked and NOT in CI (which has no egress guarantee).
* It SKIPS cleanly when the host is unreachable rather than failing a build for a network blip —
* but it distinguishes "could not reach" from "reached and wrong", and only the latter exits 1.
*/
import { readFileSync, existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { TREE_NAME } from './_tree-name.mjs'
const BASE = (process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}/`).replace(/\/?$/, '/')
const CATALOG = process.env.CATALOG || ''
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
async function get(url) {
const r = await fetch(url + (url.includes('?') ? '&' : '?') + 'cb=' + Date.now(),
{ signal: AbortSignal.timeout(30000), headers: { 'User-Agent': 'Mozilla/5.0 doxa-maps-gate' } })
if (!r.ok) throw new Error('HTTP ' + r.status)
return Buffer.from(await r.arrayBuffer())
}
let liveManifestBuf
try {
liveManifestBuf = await get(BASE + 'manifest.json')
} catch (e) {
console.log(`CATALOG-IDENTITY: SKIP — cannot reach ${BASE} (${String(e.message).slice(0, 50)}).`)
process.exit(0)
}
// ★ Assert on CONTENT, not status: /app/ returns 200 with an HTML sign-in page.
let mf
try {
mf = JSON.parse(liveManifestBuf.toString('utf8'))
} catch {
console.error('CATALOG-IDENTITY: FAIL — the manifest URL did not return JSON.')
console.error(` ${BASE}manifest.json served ${liveManifestBuf.length} bytes that do not parse.`)
console.error(' This is what the LOGIN WALL looks like: HTTP 200 with an HTML sign-in page.')
console.error(` The public door is /plugin-assets/${TREE_NAME}/ — NOT /app/${TREE_NAME}/.`)
process.exit(1)
}
const bundles = mf.bundles || []
if (!bundles.length) {
console.error('CATALOG-IDENTITY: FAIL — the live manifest lists ZERO bundles.')
process.exit(1)
}
let ok = 0, bad = [], noIntegrity = [], unreachable = []
for (const b of bundles) {
const name = b.bundle
const integ = (b.profiles || []).map((p) => p.integrity).find(Boolean) || b.integrity
if (!integ) { noIntegrity.push(name); continue }
let buf
try { buf = await get(`${BASE}${name}/${name}.js`) }
catch (e) { unreachable.push(`${name} (${String(e.message).slice(0, 24)})`); continue }
if (sha384(buf) === integ) ok++
else bad.push(name)
}
console.log(`CATALOG-IDENTITY: ${bundles.length} bundle(s) in the live manifest; ` +
`${ok} identity-proven against their own SRI.`)
for (const n of noIntegrity) console.log(` i no integrity field, skipped: ${n}`)
for (const n of unreachable) console.log(` i unreachable, skipped: ${n}`)
if (CATALOG) {
const p = join(CATALOG, 'manifest.json')
if (!existsSync(p)) {
console.error(`\nCATALOG-IDENTITY: FAIL — CATALOG=${CATALOG} has no manifest.json.`)
process.exit(1)
}
const a = sha256(liveManifestBuf), c = sha256(readFileSync(p))
if (a !== c) {
console.error('\nCATALOG-IDENTITY: FAIL — the LIVE manifest differs from the catalog checkout.')
console.error(` live sha256: ${a.slice(0, 32)}`)
console.error(` catalog sha256: ${c.slice(0, 32)}`)
console.error(' The server is serving something other than this catalog commit.')
process.exit(1)
}
console.log(` ✓ live manifest is byte-identical to the catalog checkout (sha256 ${a.slice(0, 16)}…)`)
}
if (bad.length) {
console.error(`\nCATALOG-IDENTITY: FAIL — ${bad.length} bundle(s) served bytes that do not match their SRI:`)
for (const n of bad) console.error(` ✗ ${n}`)
console.error('\n The served bundle and the manifest that describes it disagree. A browser will')
console.error(' REFUSE to execute such a script ("Failed to find a valid digest"), and the element')
console.error(' renders blank — which looks like a catastrophic bundle failure when the bundle is')
console.error(' fine and only the HASHES disagree. If this appeared within ~5 min of a publish it')
console.error(' may be the transient 300s-cache trap; re-run before investigating further.')
process.exit(1)
}
console.log('CATALOG-IDENTITY: PASS — every reachable live bundle is byte-identical to its manifest entry.')