Serve locally the way production serves, so a bad URL fails here instead of live
bun run serve was python3 -m http.server, which resolves a directory request to its index.html. The live static layer returns 404 for the same request. So a gate could pass locally on a URL that does not work in production, and nothing local could ever catch it. That is not a harness quirk, it 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. It cost a false "the publish failed, zero filters live" reading on a publish that was byte-for-byte correct. serve-strict.mjs returns 404 for a directory, exactly like production, with no index fallback and no directory listing. It also refuses to read outside its root. Loopback only, inherited from the script it replaces, whose own comment records that the bind was once 0.0.0.0 and answered on the box's public IP while a gate was running. The permissive server is kept as serve:permissive rather than deleted, so the difference can be demonstrated. It earned itself immediately: verify-shell-scroll held two bare-directory URLs and failed the first time it ran against the strict server. Both now ask for index.html, which is the form the manifest already ships for all 28 profiles, and the gate passes again -- both wheel gestures moving the grid 1500px onto screen. Fixing the server rather than the URLs is the point. The repo was already production-correct: 28 of 28 manifest URLs and 82 gate references use index.html. What was missing was anything that could fail when one did not. A rule you have to remember fails under time pressure; a server that returns 404 does not.
This commit is contained in:
parent
2b5ff1328b
commit
0f0bebd9c2
3 changed files with 105 additions and 4 deletions
95
internal/serve-strict.mjs
Normal file
95
internal/serve-strict.mjs
Normal file
|
|
@ -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.')
|
||||
})
|
||||
|
|
@ -31,8 +31,13 @@ import { chromium } from 'playwright'
|
|||
// `bun run <script> <arg>` cannot forward arguments past an inline `#` comment — see
|
||||
// check:script-flags. The positional form is kept as a fallback for direct `node` invocation.
|
||||
const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
|
||||
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/'
|
||||
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/'
|
||||
// ★ ASK FOR index.html EXPLICITLY. These were bare-directory paths, which the OLD local server
|
||||
// (python3 -m http.server) resolved to index.html while the LIVE static layer returns 404 — so this
|
||||
// gate passed for a whole flight on URLs that do not work in production. `bun run serve` is now
|
||||
// internal/serve-strict.mjs, which 404s a directory exactly as production does, and it caught these
|
||||
// two the first time it ran. The manifest already ships 28/28 iframeUrl values in this form.
|
||||
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/index.html'
|
||||
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html'
|
||||
|
||||
// Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every
|
||||
// lookup here is a recursive walk with a seen-Set, re-injected after each interaction (a page
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"verify": "node library/scripts/research/verify-all.mjs",
|
||||
"verify:full": "node library/scripts/research/verify-all.mjs --full",
|
||||
"serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
"serve": "node internal/serve-strict.mjs # serves public/ the way PRODUCTION does: a DIRECTORY request returns 404, no index fallback. This was python3 -m http.server, which DID resolve a directory to index.html while the live static layer does not — so gates passed on URLs that 404 live (measured 2026-09-23; it cost a false \"publish failed\" reading and hid a bad URL in verify-shell-scroll for a whole flight). Loopback only, same as before. Override port/host with PORT/HOST.",
|
||||
"serve:shell": "python3 -m http.server 4174 --bind 127.0.0.1 --directory library/layouts/app-wrapper-material # LOOPBACK ONLY, same reason as `serve` above — a local preview server has no business answering on a public interface.",
|
||||
"cap:sync": "./node_modules/.bin/cap sync android # NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
|
||||
"cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
|
||||
|
|
@ -117,7 +117,8 @@
|
|||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
|
||||
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test"
|
||||
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
|
||||
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
Loading…
Reference in a new issue