PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2),
and the last admin one: it moves the entire residual 33 and DELETES
admin.routes.js. Every one of the 110 admin routes is now declared in a
capability router. No URL, gate or handler changes.
shard.router.js (16) /admin/shard
uoLink.router.js ( 5) /admin/uo-link
email.router.js ( 6) /admin/email
discordBot.router.js ( 2) /admin/discord-bot
settings.router.js ( 2) /admin/settings
dashboard.router.js ( 2) GET /dashboard + PUT /site-mode, at the group root
admin.routes.js deleted, was 33
No gate moved to router level. Every adminOnly in the residual file was
per-route, and modAccess on /shard must stay per-route because half that router
must not have it — which keeps the per-route handler count intact, the one
number routes.guards.json can actually check.
/shard is the first prefix where two tiers share one router: 7 self-service
account-linking routes (no extra gate, served by the same player/shard
controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on
modAccess. Prefix ownership beats tag grouping — splitting by tag would put two
routers under one prefix for no gain. The tag mismatch stays; retagging is a
real spec diff and belongs in a PR about tags.
dashboard.router.js is the one router mounted at the group root rather than a
prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe
only because the file declares no router-level middleware — a bare use(gate) in
a root-mounted router would run for every request passing through toward
another mount. The file carries a comment saying so.
Acceptance — all four gates zero-diff:
routes.manifest.json unchanged (200 public + 2 internal)
routes.guards.json unchanged (no route lost or gained a gate)
swagger-output.json unchanged (198 operations)
api-route-inventory.json already in sync
plus 434 server tests green.
Verified separately, because no gate can catch it: introspecting the built
stack, all 59 literal admin paths still dispatch to their own layer — nothing
is captured first by a /:param sibling. The manifest sorts its entries, so
declaration order is invisible to it.
Also repoints the comments that referenced admin.routes.js by name
(botActivity/moderation controllers, the town-crier cap mirror in
announceJobs.logic.js) and generalizes the "the path is on the line after
router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which
was never about that one file.
Co-Authored-By: Claude <noreply@anthropic.com>
262 lines
9.6 KiB
JavaScript
262 lines
9.6 KiB
JavaScript
#!/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 !== '<anonymous>') {
|
|
// 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 }
|