doxa-maps/internal/serve-strict.mjs
doxa-maps-88655c70 27151f39a6 Research Gallery: boot on a partner page under the CORS the live server actually sends
The doxa.life script embed was blocked on a server change: the live root
manifest.json answers without Access-Control-Allow-Origin, and the gallery
fetch()es it cross-origin, so no tab could mount. Measured on nuxtbase,
though, the same static layer DOES send ACAO on every path inside a folder
of the tree (doxa-maps-spa/<anything>, even a 404; assets/…).

So generate-tree writes a byte-identical manifest copy at
doxa-maps-spa/manifest.json in the same step as the root one (they cannot
diverge). The gallery reads it FIRST when the tree is on another origin (no
CORS error in the host's console) and the root first when same-origin.
Script URLs still resolve against the tree root, never the copy's folder.

check:gallery-embed:live-cors serves the tree with the live rule
(serve-strict LIVE_CORS=1: no ACAO on the root manifest only).
- The current gallery under it: "did not boot within 60s" (negative
  control, reproducing doxa.life).
- With this change: js 14, iframe 14, neighbour 14, linked 4. The default
  gate also passes all phases.
check:embed-targets, check:drop and check:public-hygiene pass with the copy
present.

The server-side CORS rule (serving/traefik-embed-headers.yaml) is still the
right long-term fix, and it needs server access. This unblocks the embed
without it once the tree is published.
2026-09-25 08:55:52 +00:00

137 lines
8 KiB
JavaScript

#!/usr/bin/env node
/**
* serve-strict.mjs — serve public/ the way PRODUCTION serves it, not the way it is convenient.
*
* ★ THE DEFECT THIS EXISTS FOR, hit live 2026-09-23. `bun run serve` was
* `python3 -m http.server`, which resolves a DIRECTORY request to its index.html. The live
* nuxtbase.app static layer does NOT:
*
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/ -> 404 live, 200 local
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html -> 200 both
*
* So every headless verification could pass on a URL that 404s in production, and the divergence
* was invisible until something was finally checked against the live site. It cost a false
* "the publish failed, zero filters live" reading on a publish that was byte-for-byte correct.
*
* ★ WHY FIX THE SERVER RATHER THAN THE URLS. The repo is already production-correct: the manifest
* ships 28/28 iframeUrl values ending in /index.html, and the gates use that form 82 times. Only
* ad-hoc probes and one gate ever used the bare-directory form. So the exposure was never really
* the URLs — it was that NOTHING LOCAL COULD CATCH a bad one. A local environment MORE PERMISSIVE
* than production is a blind spot with a green light on it: every defect inside the permissive gap
* passes locally by construction and can only be found in production, which is the worst place to
* find it. Making local strict converts that class from "discovered live" to "impossible to commit".
* A rule you have to REMEMBER fails under time pressure; a server that returns 404 does not.
*
* WHAT IT DOES, deliberately minimal:
* - serves files under public/ (or --directory) on 127.0.0.1 only;
* - a request for a DIRECTORY returns 404, exactly like the live layer — no index fallback,
* no redirect-to-index, no directory listing;
* - refuses to escape the served root (a .. traversal is 403, not a file read).
*
* LOOPBACK ONLY, inherited from the script this replaces: the bind was once 0.0.0.0, which
* published the built tree on every interface of whatever machine ran a gate — measured answering
* HTTP 200 on the box's PUBLIC IP while a browser gate was running. Override with HOST only if you
* deliberately want another host to reach it.
*
* Usage: node internal/serve-strict.mjs [--port 4173] [--directory public]
*/
import { createServer } from 'node:http'
import { createReadStream, statSync } from 'node:fs'
import { resolve, join, extname, normalize } from 'node:path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const argOf = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt }
const PORT = Number(process.env.PORT || argOf('--port', '4173'))
const HOST = process.env.HOST || '127.0.0.1'
const DIR = resolve(ROOT, argOf('--directory', 'public'))
const TYPES = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8', '.geojson': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
'.csv': 'text/csv; charset=utf-8', '.pbf': 'application/x-protobuf', '.txt': 'text/plain; charset=utf-8',
}
const send = (res, code, body, headers = {}) => {
res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', ...headers })
res.end(body)
}
// ── REFUSE TO SERVE A TORN TREE (card BUILD-BROWSER-GATES-RACE-THE-BUILD-LOCK) ────────────────
// `bun run clean` rm -rf's public/doxa-maps at the START of every build, so a browser gate running
// during one reads an EMPTY tree and reports a confident wrong diagnosis. Measured: verify-shell-
// scroll said "could not find the dashboard-plus-grid tab" twice in ten minutes while a swarm
// rebuilt underneath it — a phantom failure that reads exactly like a real regression, and cost
// three ticks of this flight.
//
// ★ THE CHECK BELONGS IN THE SERVER, NOT IN EACH GATE. All nine browser gates fetch through here.
// Guarding one chokepoint means none of them can misread a torn tree and none of them needs
// editing — where "have every gate acquire the build lock" is nine changes plus a rule every FUTURE
// gate has to remember. Same reasoning as serving directories strictly: fix the thing they all pass
// through, rather than asking nine callers to be careful.
//
// A 503 is deliberate, not a 404: 404 means "this is not here", which a gate may reasonably report
// as a product defect. 503 with a named cause means "ask again later" and cannot be mistaken for a
// finding. Static assets outside the bundle tree (assets/, __gallery/) are unaffected.
const TREE = join(DIR, 'doxa-maps')
const MANIFEST = join(TREE, 'manifest.json')
function treeIsTorn() {
try { return !statSync(MANIFEST).isFile() } catch { return true }
}
createServer((req, res) => {
let pathname
try { pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname) } catch { return send(res, 400, 'bad request\n') }
if (pathname.startsWith('/doxa-maps/') && treeIsTorn()) {
return send(res, 503, 'tree is mid-build — public/doxa-maps/manifest.json is absent.\n' +
'A build rm -rf\'s this tree at its start, so anything measured now is a phantom.\n' +
'Wait for the build to finish and retry; do NOT record this as a product finding.\n',
{ 'retry-after': '30' })
}
// Never let a request escape the served root. normalize() collapses .. before we join.
const rel = normalize(pathname).replace(/^(\.\.[/\\])+/, '').replace(/^[/\\]+/, '')
const file = join(DIR, rel)
if (!file.startsWith(DIR)) return send(res, 403, 'forbidden\n')
let st
try { st = statSync(file) } catch { return send(res, 404, 'not found\n') }
// ★ THE WHOLE POINT. A directory is a 404 here because it is a 404 in production. Do not add an
// index fallback "for convenience" — the convenience is what hid a live defect for a full flight.
if (st.isDirectory()) {
return send(res, 404, 'not found — this server does not serve directory indexes, because the live\n' +
'static layer does not either. Request the file explicitly:\n' +
' ' + pathname.replace(/\/*$/, '/') + 'index.html\n')
}
res.writeHead(200, {
'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
'content-length': st.size,
'cache-control': 'no-cache',
// ★ CORS: the tree is PUBLIC STATIC ASSETS built to be embedded on OTHER sites (the
// drop-anywhere contract). A cross-origin <script> tag loads fine without this, but the
// composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch()
// enforces CORS, so on any third-party host the shell mounted and every manifest-composed
// tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the
// LIVE nuxtbase static layer has the same gap -- carded with the Traefik ready-fix).
// ACAO:* is the standard for credential-less public assets; production parity is the
// whole point of this server, so it must teach the same requirement.
// LIVE_CORS=1 reproduces the LIVE static layer's rule instead (measured on nuxtbase 2026-09-25):
// ACAO on every path INSIDE a folder of the tree, NOT on the tree-root manifest.json. Off by
// default; check:gallery-embed --live-cors uses it to prove the gallery still boots cross-origin
// under the rule partners actually get.
...(process.env.LIVE_CORS === '1' && /^\/doxa-maps\/manifest\.json$/.test(pathname) ? {} : { 'access-control-allow-origin': '*' }),
})
createReadStream(file).pipe(res)
}).listen(PORT, HOST, () => {
console.log(`serve-strict: http://${HOST}:${PORT} -> ${DIR.replace(ROOT + '/', '')}`)
console.log(' directory requests return 404 (matching production). Ask for /index.html explicitly.')
})