chore(server): freeze the URL surface with a generated route manifest
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m40s

PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.

scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.

Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.

Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.

The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.

CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:55:38 -05:00
parent cbe54fcc91
commit 1079b3fc05
8 changed files with 3128 additions and 0 deletions

View File

@@ -376,6 +376,35 @@ npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
### The route manifest (frozen URL surface)
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
the URL surface, so that carving the router files up by business capability
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
```bash
cd server
npm run routes:manifest # → routes.manifest.json + routes.guards.json
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
```
The generator walks the live Express stack (runtime introspection, not source parsing — route paths in
`admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
Two generated files, two very different meanings:
| File | Meaning of a diff |
|---|---|
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
annotated routes appear), the manifest records reality.
---
## Shard integration (uo-link)