Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
271 lines
10 KiB
JavaScript
271 lines
10 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`, and the separator before the
|
|
* parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
|
|
* `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
|
|
* `/:id` respectively. `fast_slash` is express's marker for a router mounted at
|
|
* the root, which contributes nothing.
|
|
*
|
|
* The parameterised branch went unexercised until the `admin.users.detail`
|
|
* extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
|
|
* wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
|
|
* class escaped. It threw rather than guessing, which is exactly what it is for.
|
|
*/
|
|
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
|
|
// `\/` optional and the `/` in the class optionally escaped, so this survives a
|
|
// path-to-regexp that emits either shape.
|
|
src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
|
|
const key = keys[i++]
|
|
return `${slash ? '/' : ''}:${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 }
|