From 1079b3fc05516b35f470ef5f31fc30fdd28d9885 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 14:55:38 -0500 Subject: [PATCH] chore(server): freeze the URL surface with a generated route manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/pr-checks.yml | 7 + CONTRIBUTING.md | 6 + README.md | 29 + server/package.json | 1 + server/routes.guards.json | 1943 +++++++++++++++++++++++++++++ server/routes.manifest.json | 811 ++++++++++++ server/scripts/routeManifest.js | 261 ++++ server/test/routeManifest.test.js | 70 ++ 8 files changed, 3128 insertions(+) create mode 100644 server/routes.guards.json create mode 100644 server/routes.manifest.json create mode 100644 server/scripts/routeManifest.js create mode 100644 server/test/routeManifest.test.js diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 673aaeb..b80b24f 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -40,6 +40,13 @@ jobs: run: npm ci --prefix server - name: Run server tests 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: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d322ad5..8615c74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `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 1. Fork or branch from `main`. Use a descriptive branch name diff --git a/README.md b/README.md index 67f5796..1f378c4 100644 --- a/README.md +++ b/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 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) diff --git a/server/package.json b/server/package.json index ad564c5..9f01910 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,7 @@ "dev": "nodemon src/server.js", "seed": "node db/seed.js", "swagger": "node swagger/swagger.js", + "routes:manifest": "node scripts/routeManifest.js", "test": "node --test" }, "keywords": [ diff --git a/server/routes.guards.json b/server/routes.guards.json new file mode 100644 index 0000000..5d91215 --- /dev/null +++ b/server/routes.guards.json @@ -0,0 +1,1943 @@ +{ + "$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`.", + "public": [ + { + "method": "GET", + "path": "/.well-known/assetlinks.json", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/docs.json", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/health", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/admin/account", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/account/identities", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/account/identities/:provider", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/account/totp/disable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/account/totp/enable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/account/totp/setup", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/activity", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/auth/providers", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/auth/providers", + "handlers": 14, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/auth/providers/:id", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/auth/providers/:id", + "handlers": 13, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/bot-activity", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/bot-activity/unban", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/dashboard", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/discord-bot/config", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/discord-bot/config", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/email/config", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/email/config", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/email/connect/callback", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/email/connect/start", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/email/disconnect", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/email/test", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/invites", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/invites", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/invites/:id", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/appeals", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/appeals/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/moderation/appeals/:id/claim", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/moderation/appeals/:id/resolve", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/filter-hits", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/members", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/recent", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/search", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/spam-hits", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/stats/summary", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/user/:discordId", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/user/:discordId/actions", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/user/:discordId/appeals", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/moderation/user/:discordId/notes", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/moderation/user/:discordId/notes", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/pages", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/pages", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/pages/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/pages/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/admin/pages/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/pages/:id/preview", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/pages/:id/unprotect", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/posts", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/posts", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/posts/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/posts/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/posts/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/posts/:id/announce", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/posts/:id/announce/retry", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/admin/posts/:id/publish", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/posts/upload", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth", + "multerMiddleware" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/settings", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/settings", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/account", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/accounts", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/audit", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/ban", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/broadcast", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/char/:serial", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/houses", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/kick", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/link", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/pages", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/pages/:id/close", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/pages/:id/respond", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/roster/:account", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/sales", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/unban", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/vendors/:account", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/site-mode", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/uo-link/config", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/uo-link/config", + "handlers": 8, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/uo-link/stream", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/uo-link/towncrier", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/uo-link/towncrier/:id", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/uploads", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth", + "multerMiddleware" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/users", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/users/:id", + "handlers": 8, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/users/:id/mfa/reset", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/accounts", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/houses", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/shard/link/:account", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/online", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/sales", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/standing", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/trusted-devices", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/trusted-devices", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/trusted-devices/:deviceId", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/wiki", + "handlers": 8, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/wiki/:slug", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki/:slug", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/wiki/:slug", + "handlers": 8, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/admin/wiki/:slug/publish", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki/:slug/revisions", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki/:slug/revisions/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/wiki/:slug/revisions/:id/restore", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki/categories", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/wiki/categories", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/wiki/categories/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/wiki/categories/:id", + "handlers": 7, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/wiki/tags", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/invite/:token", + "handlers": 3, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/invite/:token/accept", + "handlers": 10, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/login", + "handlers": 8, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/login/totp", + "handlers": 10, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/logout", + "handlers": 2, + "gates": [ + "attachSession" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me", + "handlers": 2, + "gates": [ + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/account", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/account/identities", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/account/identities/:provider", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/auth/me/account/password", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/recovery-codes/generate", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/account/recovery-codes/status", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/totp/disable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/totp/enable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/account/totp/setup", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/auth/me/account/username", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/devices", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/devices", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/devices/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/streams", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/subscriptions", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/subscriptions", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/sessions", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/sessions/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/trusted-devices", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/me/trusted-devices", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/me/trusted-devices", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/auth/me/trusted-devices/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/mobile/login", + "handlers": 11, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/mobile/logout", + "handlers": 5, + "gates": [ + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/mobile/refresh", + "handlers": 4, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/mobile/sso/exchange", + "handlers": 6, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/mobile/sso/start", + "handlers": 7, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/password/forgot", + "handlers": 4, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/password/reset/:token", + "handlers": 3, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/auth/password/reset/:token", + "handlers": 5, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/providers", + "handlers": 1, + "gates": [] + }, + { + "method": "POST", + "path": "/api/v1/auth/register", + "handlers": 10, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/sso/:provider/callback", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/auth/sso/:provider/link", + "handlers": 2, + "gates": [ + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/auth/sso/:provider/start", + "handlers": 2, + "gates": [] + }, + { + "method": "POST", + "path": "/api/v1/auth/sso/totp", + "handlers": 6, + "gates": [ + "backoffGuard", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/account", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/account/identities", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/player/account/identities/:provider", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/player/account/password", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/account/totp/disable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/account/totp/enable", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/account/totp/setup", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PATCH", + "path": "/api/v1/player/account/username", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/appeals", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/appeals", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/appeals/:id/withdraw", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/appeals/eligible", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/shard/account", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/accounts", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/char/:serial", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/houses", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/player/shard/link", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/roster/:account", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/sales", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/shard/vendors/:account", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/public/contact", + "handlers": 6, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/pages/:id/preview/:token", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/pages/:slug", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/posts/:category", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/posts/:category/:idOrSlug", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/settings", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/champs", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/economy", + "handlers": 3, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/feed", + "handlers": 4, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/governors", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/governors/:city/history", + "handlers": 4, + "gates": [ + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/guilds", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/houses", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/idoc", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/online", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/presence", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/status", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/shard/stream", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/status", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/version", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/api/v1/public/wiki", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/wiki/:slug", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/wiki/categories", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/wiki/tags", + "handlers": 2, + "gates": [ + "siteMode" + ] + } + ], + "internal": [ + { + "method": "GET", + "path": "/health", + "handlers": 1, + "gates": [] + }, + { + "method": "GET", + "path": "/internal/bot-config", + "handlers": 1, + "gates": [ + "requireInternalKey" + ] + } + ] +} diff --git a/server/routes.manifest.json b/server/routes.manifest.json new file mode 100644 index 0000000..7c0db62 --- /dev/null +++ b/server/routes.manifest.json @@ -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" + } + ] +} diff --git a/server/scripts/routeManifest.js b/server/scripts/routeManifest.js new file mode 100644 index 0000000..e0f362b --- /dev/null +++ b/server/scripts/routeManifest.js @@ -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 !== '') { + // 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 } diff --git a/server/test/routeManifest.test.js b/server/test/routeManifest.test.js new file mode 100644 index 0000000..e23484b --- /dev/null +++ b/server/test/routeManifest.test.js @@ -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`, + ) + } +}) -- 2.49.1