#!/usr/bin/env node /** * Route manifest generator — the machine-readable freeze of the HTTP URL surface. * * Why this exists: the router files are being carved up by business capability * (docs/website/API_V2_PLAN.md § Phase 2) with the explicit promise that not one * URL moves. "Every URL is unchanged" has to be proved by a diff, not asserted in * review, so this walks the *live* Express stack and writes a sorted * `{ method, path }` list. CI regenerates it and fails on any diff; a PR that * really does change a URL has to commit the new manifest, which puts the change * in front of a reviewer instead of letting it slip through a "mechanical" PR. * * Runtime introspection, not source parsing: it is authoritative about mounts, and * a route's path sits on the line *after* `router.get(`, which defeats naive * greps. Not swagger-output.json either — that is annotation- * derived (only annotated routes appear) and documents intent; this records reality. * * Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything * on the internal app. Three mounts in app.js are *filesystem* conditional — the SPA * catch-all `GET *`, the `/brand` static mount and swagger-ui's `/api/docs` static * assets — so including them would make the output depend on whether CI had built * the client. Static mounts are not API contract. * * Usage: * npm run routes:manifest # write server/routes.manifest.json (+ guards) * npm run routes:manifest -- --check # exit 1 if the committed files are stale */ // The apps pull in models -> utils/db, which builds a mariadb pool at require time. // Point it at a closed port (same trick the test suite uses) so generating a // manifest never opens a real connection or hangs on a missing database. process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1' process.env.DB_PORT = process.env.DB_PORT || '59999' const fs = require('fs') const path = require('path') const app = require('../src/app') const internalApp = require('../src/internalApp') const db = require('../src/utils/db') const SERVER_ROOT = path.join(__dirname, '..') const MANIFEST_PATH = path.join(SERVER_ROOT, 'routes.manifest.json') const GUARDS_PATH = path.join(SERVER_ROOT, 'routes.guards.json') const MANIFEST_COMMENT = 'Generated route inventory - the authoritative freeze of the URL surface. ' + 'Regenerate with `npm run routes:manifest` in website/server; a domain-split PR ' + 'must produce a zero-line diff here.' const GUARDS_COMMENT = 'Generated review aid, NOT a gated contract - per route, the middleware handler ' + 'count and the *named* middleware collected along the mount chain. Anonymous ' + 'handlers (e.g. the arrow returned by requireRole(...)) cannot be named, so this ' + 'is a hint for reviewers, never a security check. Regenerate with ' + '`npm run routes:manifest`.' // Only these prefixes are contract. Everything else the public app serves (SPA // shell, /uploads, /brand, swagger-ui assets) is static delivery, not API surface. const PUBLIC_PREFIXES = ['/api/', '/.well-known/'] /** * Recover the literal path a router was mounted at from the layer's regexp. * * Express keeps no copy of the mount string, only the compiled regexp. For a * literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount * contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both * gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is * express's marker for a router mounted at the root, which contributes nothing. */ function mountPath(layer) { const re = layer.regexp if (!re || re.fast_slash) return '' let src = re.source .replace(/^\^/, '') .replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') // mount tail: \/?(?=\/|$) .replace(/\$$/, '') const keys = layer.keys || [] let i = 0 src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => { const key = keys[i++] return key ? `:${key.name}` : ':param' }) // Whatever is left should be a literal path with regexp-escaped separators. src = src.replace(/\\(.)/g, '$1') if (/[()[\]?*+|^$]/.test(src)) { throw new Error( `routeManifest: could not decode mount path from regexp ${re.source} (got "${src}"). ` + 'A non-literal mount was added — teach mountPath() about it rather than guessing.', ) } return src } /** `layer.name` is 'router' for a mounted Router, and the fn name otherwise. */ function isRouter(layer) { return layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack) } /** Named middleware only — anonymous handlers have `name === ''`. */ function namedMiddleware(handlers) { return handlers .map((h) => h && h.name) .filter((n) => n && n !== 'anonymous' && n !== 'bound dispatch') } /** * Walk an Express stack, collecting one entry per (method, path). `prefix` is the * path accumulated from enclosing mounts; `gates` the named router-level middleware * seen on the way down (a `router.use(noindex, isLoggedIn, …)` gate never appears in * an individual route's own stack, so it has to be carried down). * * `depth === 0` is the app's own stack — helmet, morgan, the JSON parser, the bot * guard. Those apply to literally every route, so recording them would bury the * per-route gates that actually matter under a dozen identical names. */ function walk(stack, prefix, gates, out, depth = 0) { const inherited = [...gates] for (const layer of stack) { if (layer.route) { const routePaths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path] // The last handler is the controller, not a gate; everything before it is. const guards = layer.route.stack.slice(0, -1).map((s) => s.handle) for (const routePath of routePaths) { const full = normalize(prefix + routePath) for (const method of Object.keys(layer.route.methods)) { if (method === '_all') continue out.push({ method: method.toUpperCase(), path: full, handlers: layer.route.stack.length, gates: [...inherited, ...namedMiddleware(guards)], }) } } } else if (isRouter(layer)) { walk(layer.handle.stack, prefix + mountPath(layer), inherited, out, depth + 1) } else if (depth > 0 && layer.name && layer.name !== '') { // A bare `use()` on a mounted router — a gate applying to everything after it. inherited.push(layer.name) } } } /** Collapse `//` from empty mount paths and drop a trailing slash. */ function normalize(p) { const collapsed = p.replace(/\/{2,}/g, '/') return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed } /** Sort by path, then method — stable and diff-friendly. */ function bySurface(a, b) { if (a.path !== b.path) return a.path < b.path ? -1 : 1 if (a.method !== b.method) return a.method < b.method ? -1 : 1 return 0 } function dedupe(entries) { const seen = new Map() for (const e of entries) { const key = `${e.method} ${e.path}` if (!seen.has(key)) seen.set(key, e) } return [...seen.values()] } /** Collect the full route table for both listeners. */ function collect() { const publicRoutes = [] walk(app._router.stack, '', [], publicRoutes) const internalRoutes = [] walk(internalApp._router.stack, '', [], internalRoutes) return { public: dedupe( publicRoutes.filter((r) => PUBLIC_PREFIXES.some((p) => r.path.startsWith(p))), ).sort(bySurface), internal: dedupe(internalRoutes).sort(bySurface), } } /** The gated contract: method + path only, which is exactly what must not change. */ function buildManifest(collected) { const strip = (rs) => rs.map((r) => ({ method: r.method, path: r.path })) return { $comment: MANIFEST_COMMENT, public: strip(collected.public), internal: strip(collected.internal), } } /** The ungated review aid: same routes, plus handler count and named gates. */ function buildGuards(collected) { const shape = (rs) => rs.map((r) => ({ method: r.method, path: r.path, handlers: r.handlers, gates: [...new Set(r.gates)], })) return { $comment: GUARDS_COMMENT, public: shape(collected.public), internal: shape(collected.internal), } } // Always LF + a trailing newline so the file is byte-identical on Windows and CI. function serialize(obj) { return `${JSON.stringify(obj, null, 2)}\n` } function main() { const check = process.argv.includes('--check') const collected = collect() const files = [ [MANIFEST_PATH, serialize(buildManifest(collected))], [GUARDS_PATH, serialize(buildGuards(collected))], ] let stale = 0 for (const [file, contents] of files) { const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null if (check) { if (current !== contents) { process.stderr.write(`stale: ${path.relative(SERVER_ROOT, file)}\n`) stale += 1 } continue } fs.writeFileSync(file, contents) } const total = collected.public.length + collected.internal.length if (check) { if (stale) { process.stderr.write('Run `npm run routes:manifest` and commit the result.\n') process.exitCode = 1 } else { process.stdout.write(`route manifest up to date (${total} routes)\n`) } } else { process.stdout.write( `wrote routes.manifest.json (${collected.public.length} public + ${collected.internal.length} internal)\n`, ) } } if (require.main === module) { main() // The mariadb pool keeps the loop alive even pointed at a dead port. db.close().finally(() => process.exit(process.exitCode || 0)) } module.exports = { collect, buildManifest, buildGuards, serialize, mountPath }