chore(server): freeze the URL surface with a generated route manifest
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:
@@ -40,6 +40,13 @@ jobs:
|
|||||||
run: npm ci --prefix server
|
run: npm ci --prefix server
|
||||||
- name: Run server tests
|
- name: Run server tests
|
||||||
run: npm test --prefix server
|
run: npm test --prefix server
|
||||||
|
- name: Check the route manifest is current
|
||||||
|
# The URL surface is frozen while admin.routes.js is carved up by capability
|
||||||
|
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
||||||
|
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
|
||||||
|
# really does change one has to commit the new manifest, putting it in front
|
||||||
|
# of a reviewer instead of letting it pass silently.
|
||||||
|
run: npm run routes:manifest --prefix server -- --check
|
||||||
|
|
||||||
client-build:
|
client-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ If you add or change an API route, regenerate the Swagger spec
|
|||||||
(`cd server && npm run swagger`) and commit the updated
|
(`cd server && npm run swagger`) and commit the updated
|
||||||
`server/swagger/swagger-output.json`.
|
`server/swagger/swagger-output.json`.
|
||||||
|
|
||||||
|
The URL surface is also frozen by a generated manifest. If your change adds,
|
||||||
|
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
|
||||||
|
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
|
||||||
|
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
|
||||||
|
contract, so call it out in the PR description; a pure refactor must produce none.
|
||||||
|
|
||||||
## Branch & PR workflow
|
## Branch & PR workflow
|
||||||
|
|
||||||
1. Fork or branch from `main`. Use a descriptive branch name
|
1. Fork or branch from `main`. Use a descriptive branch name
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -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
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
not crash).
|
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)
|
## Shard integration (uo-link)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev": "nodemon src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
"swagger": "node swagger/swagger.js",
|
"swagger": "node swagger/swagger.js",
|
||||||
|
"routes:manifest": "node scripts/routeManifest.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
1943
server/routes.guards.json
Normal file
1943
server/routes.guards.json
Normal file
File diff suppressed because it is too large
Load Diff
811
server/routes.manifest.json
Normal file
811
server/routes.manifest.json
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
{
|
||||||
|
"$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.",
|
||||||
|
"public": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/.well-known/assetlinks.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/docs.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/auth/providers/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/auth/providers/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/bot-activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/bot-activity/unban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/discord-bot/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/discord-bot/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/email/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/connect/callback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/email/connect/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/email/disconnect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/email/test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/invites"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/invites"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/invites/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id/claim"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/filter-hits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/recent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/spam-hits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/stats/summary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/pages/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages/:id/preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/pages/:id/unprotect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/posts/:id/announce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts/:id/announce/retry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/posts/:id/publish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/posts/upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/audit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/ban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/broadcast"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/char/:serial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/kick"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/pages"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/respond"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/roster/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/unban"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/vendors/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/site-mode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/uo-link/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/users/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users/:id/mfa/reset"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/link/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/standing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/publish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/wiki/categories/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/wiki/categories/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/wiki/tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/invite/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/invite/:token/accept"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/login/totp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/logout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/auth/me/account/password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/recovery-codes/generate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/account/recovery-codes/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/auth/me/account/username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/devices/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/notifications/streams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/sessions/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/trusted-devices/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/logout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/refresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/mobile/sso/exchange"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/mobile/sso/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/password/forgot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/password/reset/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/password/reset/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/providers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/register"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/callback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/sso/:provider/start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/sso/totp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/account/identities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/player/account/identities/:provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/player/account/password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/disable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/enable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/account/totp/setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/player/account/username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/appeals/:id/withdraw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/appeals/eligible"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/char/:serial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/roster/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/vendors/:account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/public/contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/pages/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/posts/:category"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/posts/:category/:idOrSlug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/champs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/economy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors/:city/history"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/guilds"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/idoc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/presence"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/version"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/categories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/wiki/tags"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"internal": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/internal/bot-config"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
261
server/scripts/routeManifest.js
Normal file
261
server/scripts/routeManifest.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
#!/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
|
||||||
|
* the route paths in admin.routes.js sit on the line *after* `adminRouter.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 }
|
||||||
70
server/test/routeManifest.test.js
Normal file
70
server/test/routeManifest.test.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// The route manifest is the freeze that proves the domain split (docs/website/
|
||||||
|
// API_V2_PLAN.md § Phase 2) moves no URL. CI runs `npm run routes:manifest -- --check`,
|
||||||
|
// but that only fires on a pull request — this test makes the same drift visible on
|
||||||
|
// `npm test`, and adds the two structural invariants the manifest alone can't state.
|
||||||
|
//
|
||||||
|
// Point the pool at a closed port BEFORE requiring anything: the generator loads the
|
||||||
|
// real app, which pulls in every model and builds a mariadb pool at require time. No
|
||||||
|
// query is ever run here (the Express stack is introspected, not called).
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const manifestTool = require('../scripts/routeManifest')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const SERVER_ROOT = path.join(__dirname, '..')
|
||||||
|
const read = (file) => fs.readFileSync(path.join(SERVER_ROOT, file), 'utf8').replace(/\r\n/g, '\n')
|
||||||
|
|
||||||
|
const collected = manifestTool.collect()
|
||||||
|
|
||||||
|
test('routes.manifest.json is in sync with the live Express stack', () => {
|
||||||
|
const generated = manifestTool.serialize(manifestTool.buildManifest(collected))
|
||||||
|
assert.equal(
|
||||||
|
read('routes.manifest.json'),
|
||||||
|
generated,
|
||||||
|
'The URL surface changed. If that was deliberate, run `npm run routes:manifest` and ' +
|
||||||
|
'commit the result so the change is reviewed — do not smuggle a URL change into a ' +
|
||||||
|
'"mechanical" refactor PR.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('routes.guards.json is in sync with the live Express stack', () => {
|
||||||
|
const generated = manifestTool.serialize(manifestTool.buildGuards(collected))
|
||||||
|
assert.equal(read('routes.guards.json'), generated, 'Run `npm run routes:manifest`.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the manifest only inventories API surface, never static mounts', () => {
|
||||||
|
// The SPA catch-all, /uploads and /brand are filesystem-conditional, so including
|
||||||
|
// them would make the manifest depend on whether the client had been built.
|
||||||
|
for (const route of collected.public) {
|
||||||
|
assert.ok(
|
||||||
|
route.path.startsWith('/api/') || route.path.startsWith('/.well-known/'),
|
||||||
|
`unexpected non-API path in the manifest: ${route.method} ${route.path}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every /admin and /player route still sits behind the shared auth gate', () => {
|
||||||
|
// Router-level `use()` gates do not appear in an individual route's own stack, so a
|
||||||
|
// capability router extracted from admin.routes.js without re-applying the gate would
|
||||||
|
// silently publish authenticated endpoints. Names are only a hint — `requireRole(...)`
|
||||||
|
// returns an anonymous arrow and cannot be seen here — but a *missing* requireAuth is
|
||||||
|
// unambiguous.
|
||||||
|
const gated = collected.public.filter(
|
||||||
|
(r) => r.path.startsWith('/api/v1/admin/') || r.path.startsWith('/api/v1/player/'),
|
||||||
|
)
|
||||||
|
assert.ok(gated.length > 100, 'expected the gated surface to be found')
|
||||||
|
for (const route of gated) {
|
||||||
|
assert.ok(
|
||||||
|
route.gates.includes('requireAuth'),
|
||||||
|
`${route.method} ${route.path} is missing requireAuth`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user