doxa-maps/internal/check-tree-relative.mjs
Doxa Maps 93ebe64984 New gate check:tree-relative: the committed tree names no host of its own
An absolute embed address works where it was built and breaks everywhere
else, which is how the CDN route failed and was retired. The build config
keeps embedBaseUrl empty, so the committed tree is relative and a deploy
makes it absolute at build time (EMBED_BASE_URL). But nothing stopped a
tree built with EMBED_BASE_URL from being committed: check:drop looks for
root-relative "/..." references and deliberately skips https:// ones.

With embedBaseUrl empty in the config, the manifest must say embedBase "."
and every scriptUrl, iframeUrl and embed snippet src must be relative. Data
addresses (runtime.apiBaseUrl) are not the tree and are not checked. SKIPs
when the config sets embedBaseUrl. In gates:post, after check:drop.

Today's tree: PASS, embedBase "." and 173 embed addresses, all relative.
Poison-tested, each fails: an absolute snippet src, an absolute embedBase,
a protocol-relative iframeUrl, an empty manifest. A real deploy manifest
built with EMBED_BASE_URL fails with 174 findings, as it should.
gates:post: PASS 82, NOT MEASURED 13 (browser/server), FAIL 1: the known
check:packets-all worktree failure (no generated zone packets here).
2026-09-29 05:26:26 +00:00

72 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
// check-tree-relative — the committed tree names no host of its own.
//
// ★ WHY. An absolute embed address works where it was built and breaks everywhere else. That
// is how the CDN route failed and was retired (2026-09-29): its absolute addresses worked on
// a laptop and broke in the cloud. The build config's embedBaseUrl is "" on purpose, so the
// committed tree is relative and a deploy makes it absolute at build time (EMBED_BASE_URL,
// read by generate-tree). But a tree built with EMBED_BASE_URL set looks like any other tree,
// and check:drop cannot see the difference: it looks for root-relative "/..." references and
// deliberately skips https:// ones. Committing such a tree would pin every snippet to one host.
//
// With embedBaseUrl empty in the build config, the manifest must say embedBase "." and every
// address a page or a partner loads the tree from must be relative: each bundle's and profile's
// scriptUrl and iframeUrl, and the src of each copy-paste embed snippet. Data addresses
// (runtime.apiBaseUrl, instance props) are not the tree and are not checked. With embedBaseUrl
// set in the config, absolute addresses are intended: SKIP.
//
// Usage: node internal/check-tree-relative.mjs [manifest.json] (default: the built tree's)
// Static: reads files, starts nothing. Exit 0 pass or skip, 1 fail.
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME, BUILD_CONFIG_FILE } from './_tree-name.mjs'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
const configured = typeof cfg.embedBaseUrl === 'string' ? cfg.embedBaseUrl.trim() : ''
if (configured) {
console.log(`TREE-RELATIVE: SKIP — the build config sets embedBaseUrl (${configured}), so absolute embed addresses are intended`)
process.exit(0)
}
const file = process.argv[2] ? resolve(process.argv[2]) : join(REPO, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(file)) {
console.error(`TREE-RELATIVE: FAIL — no manifest at ${file} (build the tree first)`)
process.exit(1)
}
const manifest = JSON.parse(readFileSync(file, 'utf8'))
const absolute = (u) => /^[a-z][a-z0-9+.-]*:/i.test(u) || u.startsWith('//')
const srcs = (html) => [...String(html || '').matchAll(/\bsrc="([^"]*)"/g)].map((m) => m[1])
const fails = []
let checked = 0
const check = (where, url) => {
if (typeof url !== 'string' || !url) return
checked += 1
if (absolute(url)) fails.push(`${where}: ${url}`)
}
if (manifest.embedBase !== '.' && manifest.embedBase !== '') fails.push(`embedBase: ${JSON.stringify(manifest.embedBase)}`)
for (const b of manifest.bundles || []) {
check(`${b.bundle} scriptUrl`, b.scriptUrl)
for (const p of b.profiles || []) {
const at = `${b.bundle}/${p.profile}`
check(`${at} scriptUrl`, p.scriptUrl)
check(`${at} iframeUrl`, p.iframeUrl)
for (const s of srcs(p.embed?.script)) check(`${at} script snippet src`, s)
for (const s of srcs(p.embed?.iframe)) check(`${at} iframe snippet src`, s)
}
}
// Refuse to pass vacuously: a manifest with no addresses means the walk or the shape is wrong.
if (checked === 0) fails.push('no embed addresses found in the manifest: the check read nothing')
if (fails.length) {
console.error(`TREE-RELATIVE: FAIL — ${fails.length} problem(s) in ${file}`)
for (const f of fails.slice(0, 20)) console.error(` ✗ ${f}`)
if (fails.length > 20) console.error(` … and ${fails.length - 20} more`)
console.error(' A tree built with EMBED_BASE_URL is for a deploy, not a commit: rebuild it without EMBED_BASE_URL.')
process.exit(1)
}
console.log(`TREE-RELATIVE: PASS — embedBase "${manifest.embedBase}" and ${checked} embed addresses, all relative`)