chore(server): freeze the URL surface with a generated route manifest (PR 0) #99

Merged
whitlocktech merged 1 commits from chore/route-manifest into main 2026-07-27 20:07:32 +00:00
Member

What & why

PR 0 of the router domain splitdocs/website/API_V2_PLAN.md § Phase 2. No router file moves in this PR.

The split promises that admin.routes.js (1552 lines, 110 routes) can be carved into one router file per business capability with every URL unchanged. That promise has to be provable by a diff rather than asserted in review, otherwise each later PR is "trust me, it's mechanical". This lands the tool that proves it.

server/scripts/routeManifest.js (npm run routes:manifest) walks the live Express stack and writes a sorted { method, path } list to server/routes.manifest.json.

  • Runtime introspection, not source parsing. It is authoritative about mounts, and the route paths in admin.routes.js sit on the line after adminRouter.get(, which defeats naive greps.
  • Not swagger-output.json. That is annotation-derived (an unannotated route is invisible) and churns when a description is reworded — it documents intent. The manifest records reality.
  • Scope is /api/** + /.well-known/** plus the internal listener. The SPA catch-all, /uploads and /brand are filesystem-conditional static mounts; including them would make the output depend on whether CI had built the client. Static mounts are not API contract.

The generator reproduced the frozen baseline in docs/website/api-route-inventory.json byte-for-byte on its first run — 199 public routes + 2 internal. That was PR 0's own acceptance test, and it means the committed baseline is confirmed accurate rather than merely claimed.

CI now runs npm run routes:manifest -- --check on every PR, so a URL change can only merge by deliberately committing the new manifest — putting it in front of a reviewer instead of letting it ride along in a "mechanical" refactor.

Also: routes.guards.json — a review aid, explicitly not a contract

Per route: the middleware handler count plus the named middleware on its mount chain. It exists because a router-level router.use(noindex, isLoggedIn, staffOnly) gate never appears in an individual route's own stack — so a capability router extracted without re-applying its gate would publish authenticated endpoints silently, and the manifest (method + path only) would show nothing.

Names are a hint only: requireRole(...) returns an anonymous arrow and cannot be observed. But a vanished requireAuth is unambiguous, so the test suite asserts it directly from the introspected stack for all 130 /admin/** and /player/** routes.

App-level plumbing (helmet, morgan, the JSON parser, the bot guard) is dropped from the output — it applies uniformly to all 199 routes and would bury the per-route gates.

Deviation from the plan: the unauthenticated-status snapshot was tried and dropped

The plan offered an optional stronger check — fire an unauthenticated request at every manifest path and snapshot the status code, catching a dropped adminOnly as 403 → 200 in a way names cannot — and said to drop it rather than ship a flaky gate.

Dropped. Against the dead-port mariadb pool the test suite uses, DB-touching routes do not fail fast; they sit on the pool's acquire timeout. A partial sweep had not finished after two minutes. A flaky two-minute gate is worse than none. The requireAuth assertion above covers the same class of regression deterministically and runs in ~1 s.

Docs companion: RunicGateway/docs#49.

How it was tested

cd website/server
npm test                              # 425 tests, 0 fail (4 new)
npm run routes:manifest -- --check    # "route manifest up to date (201 routes)"
diff docs/website/api-route-inventory.json server/routes.manifest.json   # byte-identical

Check mode was verified in both directions: hand-editing /api/health/api/healthz in the committed manifest makes it exit 1 with stale: routes.manifest.json; reverting returns exit 0.

No route was added, changed or removed, so npm run swagger produces no diff and the spec is untouched.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **PR 0 of the router domain split** — `docs/website/API_V2_PLAN.md` § Phase 2. No router file moves in this PR. The split promises that `admin.routes.js` (1552 lines, 110 routes) can be carved into one router file per business capability **with every URL unchanged**. That promise has to be provable by a diff rather than asserted in review, otherwise each later PR is "trust me, it's mechanical". This lands the tool that proves it. `server/scripts/routeManifest.js` (`npm run routes:manifest`) walks the **live Express stack** and writes a sorted `{ method, path }` list to `server/routes.manifest.json`. - **Runtime introspection, not source parsing.** It is authoritative about mounts, and the route paths in `admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats naive greps. - **Not `swagger-output.json`.** That is annotation-derived (an unannotated route is invisible) and churns when a description is reworded — it documents *intent*. The manifest records *reality*. - **Scope is `/api/**` + `/.well-known/**` plus the internal listener.** The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static mounts; including them would make the output depend on whether CI had built the client. Static mounts are not API contract. **The generator reproduced the frozen baseline in `docs/website/api-route-inventory.json` byte-for-byte on its first run** — 199 public routes + 2 internal. That was PR 0's own acceptance test, and it means the committed baseline is confirmed accurate rather than merely claimed. CI now runs `npm run routes:manifest -- --check` on every PR, so a URL change can only merge by deliberately committing the new manifest — putting it in front of a reviewer instead of letting it ride along in a "mechanical" refactor. ### Also: `routes.guards.json` — a review aid, explicitly *not* a contract Per route: the middleware handler count plus the **named** middleware on its mount chain. It exists because a router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's own stack — so a capability router extracted without re-applying its gate would publish authenticated endpoints silently, and the manifest (method + path only) would show nothing. Names are a *hint* only: `requireRole(...)` returns an anonymous arrow and cannot be observed. But a **vanished `requireAuth` is unambiguous**, so the test suite asserts it directly from the introspected stack for all 130 `/admin/**` and `/player/**` routes. App-level plumbing (helmet, morgan, the JSON parser, the bot guard) is dropped from the output — it applies uniformly to all 199 routes and would bury the per-route gates. ### Deviation from the plan: the unauthenticated-status snapshot was tried and dropped The plan offered an optional stronger check — fire an unauthenticated request at every manifest path and snapshot the status code, catching a dropped `adminOnly` as `403 → 200` in a way names cannot — and said to drop it rather than ship a flaky gate. Dropped. Against the dead-port mariadb pool the test suite uses, DB-touching routes do not fail fast; they sit on the pool's acquire timeout. A partial sweep had not finished after two minutes. A flaky two-minute gate is worse than none. The `requireAuth` assertion above covers the same class of regression deterministically and runs in ~1 s. Docs companion: **RunicGateway/docs#49**. ## How it was tested ``` cd website/server npm test # 425 tests, 0 fail (4 new) npm run routes:manifest -- --check # "route manifest up to date (201 routes)" diff docs/website/api-route-inventory.json server/routes.manifest.json # byte-identical ``` Check mode was verified in both directions: hand-editing `/api/health` → `/api/healthz` in the committed manifest makes it exit 1 with `stale: routes.manifest.json`; reverting returns exit 0. No route was added, changed or removed, so `npm run swagger` produces no diff and the spec is untouched. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-27 19:57:11 +00:00
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
1079b3fc05
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>
whitlocktech approved these changes 2026-07-27 20:01:30 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-27 20:01:35 +00:00
whitlocktech merged commit 49b70ee04d into main 2026-07-27 20:07:32 +00:00
whitlocktech deleted branch chore/route-manifest 2026-07-27 20:07:32 +00:00
Sign in to join this conversation.
No description provided.