diff --git a/internal/serve-strict.mjs b/internal/serve-strict.mjs new file mode 100644 index 000000000..f8a4652f8 --- /dev/null +++ b/internal/serve-strict.mjs @@ -0,0 +1,95 @@ +#!/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) +} + +createServer((req, res) => { + let pathname + try { pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname) } catch { return send(res, 400, 'bad request\n') } + + // 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', + }) + 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.') +}) diff --git a/internal/verify-shell-scroll.mjs b/internal/verify-shell-scroll.mjs index 4238570a3..ed30067b3 100644 --- a/internal/verify-shell-scroll.mjs +++ b/internal/verify-shell-scroll.mjs @@ -31,8 +31,13 @@ import { chromium } from 'playwright' // `bun run