refactor(server): split public, player and residual auth into capability routers (PR 5) #106

Merged
whitlocktech merged 1 commits from refactor/router-split-5 into main 2026-07-28 02:04:59 +00:00
Member

What & why

The last split PR of docs/website/API_V2_PLAN.md § Phase 2 — the domain split is complete.

public.routes.js, player.routes.js and auth.routes.js are deleted. Each group is now a directory whose index.js owns the group gate and the mount table and declares no routes of its own, matching admin/. Every one of the 200 routes in the manifest is now declared in a capability router; no monolithic route file remains in router/v1/.

Group Router Routes Prefix Extra gate
public posts.router.js 2 /public/posts none — siteMode per route
wiki.router.js 4 /public/wiki none — siteMode per route
pages.router.js 2 /public/pages none — siteMode per route except the preview
shard.router.js 12 /public/shard none — never siteMode gated
site.router.js 4 group root (/settings /status /version /contact) none
player account.router.js 8 /player/account none beyond the group gate
shard.router.js 8 /player/shard none beyond the group gate
appeals.router.js 4 /player/appeals none beyond the group gate
auth login.router.js 2 /auth/login loginGuards per route
register.router.js 1 /auth/register loginGuards + registerLimiter
invite.router.js 2 /auth/invite loginGuards + registerLimiter on accept
password.router.js 3 /auth/password per-route reset limiters
session.router.js 2 group root (/logout /me) per route

24 + 20 + 10 = 54. auth/'s other 32 routes (me 23, mobile 5, sso 4) were already in their own files and did not move.

No URL moves. Docs PR: RunicGateway/docs#60.

The parts that are not mechanical — please look here

  • Two of the three groups have no group gate, and that is the security-relevant fact about them. player/index.js carries noindex, requireAuth — authenticated, any role, because staff are a superset of players. public/index.js and auth/index.js carry nothing, deliberately: the public surface is anonymous by contract (logged-out SPA, Discord bot, and the Android ShardStreamClient on /public/shard/stream, none of which send credentials), and /auth is where an anonymous caller becomes authenticated. Both index files say so, because the obvious "hardening" edit to either one is an outage.

  • GET /auth/me has a mount-order dependency. authRouter.use('/me', meRouter) matches the bare path /me, not just /me/* — so the request runs meRouter's (and notifRouter's) noindex, requireAuth, matches no route inside either, and falls through to its own handler. session.router.js must therefore stay mounted last. Verified by the counterfactual rather than by reading the mount table: moving the mount to the top of auth/index.js still answers 401, but the response loses its X-Robots-Tag header. Neither manifest can see that; only a header assertion can.

  • Two root-mounted routers, on the PR 4 dashboard.router.js precedent: public/site.router.js and auth/session.router.js hold the routes owning no path segment. Safe at the root only because neither declares router-level middleware — a bare use(gate) there runs for every request passing through toward another mount. Both files carry that note.

  • loginGuards is the PR's one shared module, the counterpart to PR 3's imageUpload.js. The [backoffGuard, slowLogin, loginLimiter] array was inline in auth.routes.js, spread by four routes this PR puts in three files — plus a fifth, already-duplicated copy in sso.routes.js. Now one definition in auth/loginGuards.js, imported by all five, exported Object.freezed (it is module-level shared state; a router that pushed onto it would silently add middleware to every other login surface).

Deviations from the plan's target tree

  • session.router.js is new — the tree listed login.router.js but had nowhere for /logout and GET /me, which own no prefix. Folding them into login.router.js would have forced that router to the group root and given up prefix ownership for the four login routes.
  • posts.router.js, not news.router.js — matches the /posts prefix it owns and its admin/posts.router.js sibling.
  • sso.routes.js / mobile.routes.js were not renamed to *.router.js. They did not move in this PR; churning their names would add diff noise to a PR whose value is being reviewable.
  • public.controller.js was not split. It still serves settings/status/version/contact and posts/wiki/pages. These PRs re-wire routes, not logic; splitting a controller is a separate change with a separate risk profile.
  • Tag mismatches left alone again, on the PR 1 / PR 3 / PR 4 precedent — retagging is a real spec diff and belongs in a PR that is about tags.

How it was tested

All four gates zero-diff (none of these files appears in the commit):

  • server/routes.manifest.json — 200 public + 2 internal
  • server/routes.guards.json — no route lost or gained a gate
  • server/swagger/swagger-output.json — 198 operations, byte-identical
  • docs/website/api-route-inventory.json — already in sync
cd website/server
npm run routes:manifest    # 200 public + 2 internal, git diff clean
npm run swagger            # 198 operations, git diff clean
npm test                   # 434 passing, 0 failing

Two checks the manifest provably cannot perform, both run explicitly:

  • :param shadowing — the manifest sorts its entries, so a declaration reordering is invisible in all four gates. Walked the built stack in dispatch order: 86 public/player/auth routes, 64 of them literal, none shadowed. The only ordering-sensitive pair is GET /public/wiki/{categories,tags} ahead of /public/wiki/:slug (the public twin of the admin/wiki.router.js trap PR 3 found); /public/pages/:id/preview/:token also stays ahead of /:slug, at a different depth.
  • The /auth/me header assertion described above — confirmed X-Robots-Tag: noindex, nofollow is still present on the split, matching main, and confirmed it disappears if session.router.js is mounted first.

Checklist

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

AI-assisted contributions (required)

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

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **The last split PR of [docs/website/API_V2_PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/API_V2_PLAN.md) § Phase 2 — the domain split is complete.** `public.routes.js`, `player.routes.js` and `auth.routes.js` are deleted. Each group is now a directory whose `index.js` owns the group gate and the mount table and declares no routes of its own, matching `admin/`. Every one of the 200 routes in the manifest is now declared in a capability router; no monolithic route file remains in `router/v1/`. | Group | Router | Routes | Prefix | Extra gate | |---|---|---|---|---| | `public` | `posts.router.js` | 2 | `/public/posts` | none — `siteMode` per route | | | `wiki.router.js` | 4 | `/public/wiki` | none — `siteMode` per route | | | `pages.router.js` | 2 | `/public/pages` | none — `siteMode` per route except the preview | | | `shard.router.js` | 12 | `/public/shard` | none — never `siteMode` gated | | | `site.router.js` | 4 | **group root** (`/settings` `/status` `/version` `/contact`) | none | | `player` | `account.router.js` | 8 | `/player/account` | none beyond the group gate | | | `shard.router.js` | 8 | `/player/shard` | none beyond the group gate | | | `appeals.router.js` | 4 | `/player/appeals` | none beyond the group gate | | `auth` | `login.router.js` | 2 | `/auth/login` | `loginGuards` per route | | | `register.router.js` | 1 | `/auth/register` | `loginGuards` + `registerLimiter` | | | `invite.router.js` | 2 | `/auth/invite` | `loginGuards` + `registerLimiter` on accept | | | `password.router.js` | 3 | `/auth/password` | per-route reset limiters | | | `session.router.js` | 2 | **group root** (`/logout` `/me`) | per route | 24 + 20 + 10 = 54. `auth/`'s other 32 routes (`me` 23, `mobile` 5, `sso` 4) were already in their own files and did not move. **No URL moves.** Docs PR: RunicGateway/docs#60. ### The parts that are not mechanical — please look here - **Two of the three groups have no group gate, and that is the security-relevant fact about them.** `player/index.js` carries `noindex, requireAuth` — authenticated, *any* role, because staff are a superset of players. `public/index.js` and `auth/index.js` carry **nothing**, deliberately: the public surface is anonymous by contract (logged-out SPA, Discord bot, and the Android `ShardStreamClient` on `/public/shard/stream`, none of which send credentials), and `/auth` is where an anonymous caller *becomes* authenticated. Both index files say so, because the obvious "hardening" edit to either one is an outage. - **`GET /auth/me` has a mount-order dependency.** `authRouter.use('/me', meRouter)` matches the *bare* path `/me`, not just `/me/*` — so the request runs `meRouter`'s (and `notifRouter`'s) `noindex, requireAuth`, matches no route inside either, and falls through to its own handler. `session.router.js` must therefore stay mounted **last**. Verified by the counterfactual rather than by reading the mount table: moving the mount to the top of `auth/index.js` still answers `401`, but the response loses its `X-Robots-Tag` header. Neither manifest can see that; only a header assertion can. - **Two root-mounted routers**, on the PR 4 `dashboard.router.js` precedent: `public/site.router.js` and `auth/session.router.js` hold the routes owning no path segment. Safe at the root **only** because neither declares router-level middleware — a bare `use(gate)` there runs for every request passing through toward another mount. Both files carry that note. - **`loginGuards` is the PR's one shared module**, the counterpart to PR 3's `imageUpload.js`. The `[backoffGuard, slowLogin, loginLimiter]` array was inline in `auth.routes.js`, spread by four routes this PR puts in three files — plus a fifth, already-duplicated copy in `sso.routes.js`. Now one definition in `auth/loginGuards.js`, imported by all five, exported `Object.freeze`d (it is module-level shared state; a router that pushed onto it would silently add middleware to every other login surface). ### Deviations from the plan's target tree - **`session.router.js` is new** — the tree listed `login.router.js` but had nowhere for `/logout` and `GET /me`, which own no prefix. Folding them into `login.router.js` would have forced *that* router to the group root and given up prefix ownership for the four login routes. - **`posts.router.js`, not `news.router.js`** — matches the `/posts` prefix it owns and its `admin/posts.router.js` sibling. - **`sso.routes.js` / `mobile.routes.js` were not renamed** to `*.router.js`. They did not move in this PR; churning their names would add diff noise to a PR whose value is being reviewable. - **`public.controller.js` was not split.** It still serves settings/status/version/contact *and* posts/wiki/pages. These PRs re-wire routes, not logic; splitting a controller is a separate change with a separate risk profile. - Tag mismatches left alone again, on the PR 1 / PR 3 / PR 4 precedent — retagging is a real spec diff and belongs in a PR that is about tags. ## How it was tested **All four gates zero-diff** (none of these files appears in the commit): - `server/routes.manifest.json` — 200 public + 2 internal - `server/routes.guards.json` — no route lost or gained a gate - `server/swagger/swagger-output.json` — 198 operations, byte-identical - `docs/website/api-route-inventory.json` — already in sync ``` cd website/server npm run routes:manifest # 200 public + 2 internal, git diff clean npm run swagger # 198 operations, git diff clean npm test # 434 passing, 0 failing ``` Two checks the manifest provably **cannot** perform, both run explicitly: - **`:param` shadowing** — the manifest sorts its entries, so a declaration reordering is invisible in all four gates. Walked the built stack in *dispatch* order: 86 public/player/auth routes, 64 of them literal, **none shadowed**. The only ordering-sensitive pair is `GET /public/wiki/{categories,tags}` ahead of `/public/wiki/:slug` (the public twin of the `admin/wiki.router.js` trap PR 3 found); `/public/pages/:id/preview/:token` also stays ahead of `/:slug`, at a different depth. - **The `/auth/me` header assertion** described above — confirmed `X-Robots-Tag: noindex, nofollow` is still present on the split, matching `main`, and confirmed it disappears if `session.router.js` is mounted first. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-28 01:54:58 +00:00
refactor(server): split public, player and residual auth into capability routers (PR 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s
565a7d2c20
The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

  public/  posts (2) wiki (4) pages (2) shard (12) site (4, group root)
  player/  account (8) shard (8) appeals (4), behind noindex + requireAuth
  auth/    login (2) register (1) invite (2) password (3) session (2, root)

No URL moves. All four gates zero-diff: routes.manifest.json (200 public + 2
internal), routes.guards.json, swagger-output.json (198 operations), and
docs/website/api-route-inventory.json was already in sync. 434 tests green.

Notes on the non-mechanical parts:

- public/index.js and auth/index.js carry no group gate, deliberately, and say
  so. The public surface is anonymous by contract (logged-out SPA, Discord bot,
  Android ShardStreamClient on /public/shard/stream); /auth is where a caller
  becomes authenticated. player/index.js gates on requireAuth only, never
  requireRole('player') — staff are a superset of players.
- GET /auth/me has a mount-order dependency: use('/me', meRouter) matches the
  bare /me, so the request runs meRouter's noindex + requireAuth and falls
  through. session.router.js must stay mounted last. Verified by the
  counterfactual — mounting it first still 401s but drops X-Robots-Tag, which
  no manifest or guards file can see.
- loginGuards moved to auth/loginGuards.js (frozen) rather than being copied
  into the three routers that spread it; sso.routes.js drops its duplicate.
- The :param shadowing check was re-run in dispatch order against the built
  stack: 86 routes, 64 literal, none shadowed. /public/wiki/{categories,tags}
  ahead of /:slug is the only ordering-sensitive pair.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-28 01:57:51 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-28 01:57:55 +00:00
whitlocktech merged commit 068844bfd9 into main 2026-07-28 02:04:59 +00:00
whitlocktech deleted branch refactor/router-split-5 2026-07-28 02:05:00 +00:00
Sign in to join this conversation.
No description provided.