doxa-maps/internal/serve-strict.mjs
doxa-maps-88655c70 79bd6d2ac3
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).

- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
  public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
  is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
  before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
  to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
  (all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
  the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
  there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
  step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
  start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
  excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
  checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
  could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
  the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
  check:stale-sweep-claim's command (it was already in //scripts).

Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
2026-09-25 17:41:56 +00:00

139 lines
8.2 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'
import { TREE_NAME, TREE_NAME_RE } from './_tree-name.mjs'
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. Anything outside the bundle tree is unaffected.
const TREE = join(DIR, TREE_NAME)
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(`/${TREE_NAME}/`) && treeIsTorn()) {
return send(res, 503, `tree is mid-build — public/${TREE_NAME}/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' && new RegExp(`^/${TREE_NAME_RE}/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.')
console.log(` front page (the Plugin Studio): http://${HOST}:${PORT}/${TREE_NAME}/index.html`)
})