Files
docs/website/API_V2_PLAN.md
wtclaude 257ed2166c docs(website): record PR 5 — public, player and auth capability split
The domain split is complete. API_V2_PLAN.md gains a "PR 5 — as landed"
section (route table, the four zero-diff gates, and the findings worth
carrying forward) and its status line and sequencing list are updated: only
the CSP enforce PR remains, blocked on soak data rather than on code.

BACKEND_DESIGN.md §2 replaces the auth.routes.js / public.routes.js entries
with the full per-capability tree for auth/, public/ and player/, and §4's
group headings now point at the index.js files. The /player prose names the
three routers behind the shared gate.

Findings recorded rather than left in the code alone:

- public/ and auth/ deliberately have no group gate — the obvious hardening
  edit to either is an outage.
- GET /auth/me depends on session.router.js being mounted last, because
  use('/me', meRouter) matches the bare /me and supplies its noindex header.
- Two root-mounted routers (public/site, auth/session) on the PR 4 dashboard
  precedent, safe only because neither declares router-level middleware.
- loginGuards is the PR's shared module, the counterpart to PR 3's
  imageUpload.js.
- Filename deviations from the target tree (posts not news, session.router.js
  added) and why public.controller.js was not split.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:29 -05:00

49 KiB
Raw Permalink Blame History

Website API — router domain split + CSP hardening

Status: domain split complete — PR 0 (route manifest), CSP report-only and split PRs 15 have all landed; only the CSP enforce PR remains, and it is blocked on soak data rather than on code · Target repo: website/ · Docs owner: this file + BACKEND_DESIGN.md

This file replaces the earlier "API v2" plan (auth merge → CSP → domain split, with a parallel /api/v2 mount and an /api/mobile facade). Three of those four pieces are not being built: the auth merge and the mobile facade are deferred with their reasoning recorded below, and the parallel-version scaffold in API_V2_SKELETON.md is superseded. The filename is kept so existing links resolve. What remains is genuinely useful work:

  1. CSP hardening — small, independent, ships on its own cadence.
  2. The domain splitadmin.routes.js (1552 lines, 110 routes) broken into one router file per business capability, in place, with every URL unchanged. This is the actual driver.

Why the auth merge is out

The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for every client, so web and mobile would share one session model. Reasons that no longer hold up:

  1. The current model is the more secure one. httpOnly + SameSite cookies are unreadable from JS and carry CSRF protection by default. Every migration target is a sideways or backwards move:
    • Refresh token in localStorage → any XSS becomes persistent full account takeover, not a bounded access-token window. A short access TTL does not help; the attacker mints new pairs.
    • Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is cookies with extra steps. It concedes the premise.
  2. "One session model everywhere" is already true where it matters. auth/session.service.js unifies cookie and bearer into a single session, and auth/token.js already extracts from either Cookie or Authorization: Bearer. That abstraction is written, working, and paid for. The merge would move complexity out of extractToken and into the SPA.
  3. The cookie codepath survives the merge anyway. The SSO / email-connect redirect flow must keep its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the IdP and returns with no JS context. So the merge never actually delivered "cookies are gone."
  4. It carried the plan's most bug-prone work as a dependency. The admin SSE rewrite (EventSource → hand-rolled fetch + ReadableStream + SSE frame parser + reconnect/backoff + refresh-on-401) existed only to serve the bearer model. Without the merge, the admin stream stays on EventSource with withCredentials and that code is never written.
  5. It is a contract change with no user-visible payoff, competing for the same review attention as the domain split, which is the thing that actually hurts today.

Dropped with it

  • v2 bearer auth routes (/api/v2/auth/{login,refresh,logout,login/totp}).
  • Deletion of the setAuthCookie / clearAuthCookie path.
  • rg_trust cookie → X-Trust-Token header migration for web (the header stays available for native clients via extractTrustToken, unchanged).
  • api/client.js bearer + silent-refresh rewrite.
  • lib/useShardFeed.js admin-stream fetch rewrite. The admin SSE stream stays as-is.

Kept from it

  • CSP hardening — now its own phase (below). It was justified as a compensating control for a JS-held token; it is worth doing regardless, just no longer urgent.
  • The public/admin SSE allowlist split — unchanged security boundary, unrelated to session model.
  • The tx-cookie carve-out reasoning — recorded here so a future merge attempt doesn't rediscover it.

Deferred: the auth merge

Not cancelled — parked behind trigger conditions. Revisit if any of these become true:

Trigger Why it changes the answer
The API becomes genuinely cross-origin (separate API host) SameSite cookies stop being the easy path; bearer becomes the natural model.
Third-party or OAuth clients are introduced Cookies don't serve clients you don't control.
Mobile and web session behavior diverge enough to cause real bugs The unification argument gets teeth it currently lacks.

If it is ever revived, two specs the original plan lacked must be written first:

  • Refresh-token reuse detection. Rotation is only useful with it: replay of an already-consumed refresh must revoke the entire token family, not just fail the one request.
  • Rollback procedure. Once web clients have discarded their cookies, a bad deploy locks everyone out. Needs a documented path back.

Why there is no /api/v2

The domain split reorganizes router files. It does not need to move a single URL — because the URL surface is already grouped by capability. Inventory taken from the live Express stack — 196 /api/v1 routes, plus three outside it (GET /api/health, GET /api/docs.json, GET /.well-known/assetlinks.json) and 2 on the internal port. Full machine-readable list: api-route-inventory.json.

Group Routes Second segment → capability
/admin 110 shard 16 · moderation 15 · users 15 · wiki 14 · posts 9 · pages 7 · account 6 · email 6 · uo-link 5 · auth 4 · invites 3 · bot-activity 2 · discord-bot 2 · settings 2 · activity 1 · dashboard 1 · site-mode 1 · uploads 1
/auth 42 me 23 · mobile 5 · sso 4 · password 3 · invite 2 · login 2 · logout 1 · providers 1 · register 1
/public 24 shard 12 · wiki 4 · pages 2 · posts 2 · contact 1 · settings 1 · status 1 · version 1
/player 20 account 8 · shard 8 · appeals 4

Every capability already owns a URL prefix, so each new router file mounts at the prefix it already owns and the emitted paths are byte-identical. No URL change means no contract change, and no contract change means no reason to mount a parallel version.

Consequences of doing it in place:

  • No /api/v2, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no v1-retirement sequence.
  • BASE = /api/v1 in client/src/api/client.js never changes. The Discord bot's SITE_PUBLIC_URL never changes. The Android app is untouched.
  • API_V2_SKELETON.md (the router/v2/ scaffold) is superseded and not scheduled. It is kept as the concrete recipe if a real contract break ever forces a versioned API.

Tripwire: if any endpoint turns out to need a new URL, that is a contract change, not a refactor. List it explicitly, and reopen the versioning question before writing the code — do not smuggle a URL change into a "mechanical" PR.


Deferred: the /api/mobile facade and the app-version floor

The earlier plan's Phase 0 stood up a version-agnostic /api/mobile namespace and migrated the Android app onto it, plus an app-version header and a server-side min-version floor.

Why it was proposed: the app hardcodes 69 distinct api/v1/… paths (data/api/*.kt, core/net/ShardStreamClient.kt, core/net/HostSelectionInterceptor.kt, core/auth/sso/SsoAuthManager.kt), has no version negotiation and no force-update, and installs in the wild cannot be forced forward. Under a parallel-/api/v2 plan that made the app the load-bearing coupling: v1 could not be retired until the fleet aged out.

Why it is deferred: with the split done in place, no URL moves and nothing is being deleted — so there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained delegate routes plus a contract-test suite to solve a problem that does not currently exist. The version floor was scoped to sunsetting the pre-facade fleet, so it goes with it.

If it is ever revived (the trigger is the mobile contract genuinely needing to diverge from web — different response shapes, a mobile-only aggregation endpoint, a real breaking change):

  • Start with the alias mount, not a delegate layer: apiRouter.use('/mobile', v1Router) gives the app a stable, version-agnostic namespace with identical wiring, identical middleware and zero per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
  • A facade is a security surface, not a convenience alias. Any hand-written route must carry the same middleware chain as the route it mirrors (requireAuth, staffOnly/adminOnly, validators, the public/admin SSE allowlist split). A re-exposed admin route missing adminOnly is privilege escalation.
  • It needs contract tests. The moment the app pins a namespace, its response shapes are a committed contract; an internal refactor that changes a shape must fail a test before it ships to installed apps.
  • The mobile SSE stream stays anonymous under whatever path it gets — the app sends no Authorization header.

Phase 1 — CSP hardening (independent)

Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in JS, this is defense in depth on its own merits — cheap, worth doing, blocking nothing. It has no dependency on the domain split and can ship at any time.

Sequencing fix from the original plan: the old version both "ships with the auth merge" and called for a one-release report-only soak. Those contradict. Correct order is report-only first, observe one release, then enforce — now trivially satisfiable since nothing waits on it.

The app already ships a tuned policy (server/src/app.js). Two directives are load-bearing and are already correct — the job is to keep them that way:

  • script-src 'self' — no 'unsafe-inline' / 'unsafe-eval'. Primary defense.
  • connect-src 'self' — the exfiltration channel. Don't widen it unless the API genuinely becomes cross-origin (which would also reopen the auth-merge question — see the trigger table).

style-src 'unsafe-inline' stays — it permits inline styling, not script execution, and React's pervasive style={{…}} attributes can't be nonce'd. Not a meaningful hole. The /api/docs route keeps its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is scoped to the one route and stays scoped.

Target enforced policy:

default-src 'self';
script-src 'self';
connect-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';          /* + fonts.googleapis.com only until fonts are self-hosted */
font-src 'self';                            /* + fonts.gstatic.com only until fonts are self-hosted */
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';

Delta vs. the policy in server/src/app.js today. This was written as two directives; on implementation it turned out to be one:

  • Add form-action 'self' (currently absent)it was not absent. The directives object in app.js does not list it, but the middleware is configured useDefaults: true, and helmet's default set already supplies form-action 'self' — so the header served in production has carried it all along. Verified by capturing the live Content-Security-Policy header from the running app rather than reading the config, which is how the plan got this wrong. No behavioural change here. It is now written out explicitly in config/csp.js anyway: a security directive should not depend on a third-party library's defaults surviving its next major version.
  • Tighten frame-ancestors 'self''none' — nothing legitimately frames the site. This is the entire behavioural delta of the phase.
  • Unchanged: default-src, script-src, connect-src, object-src 'none', base-uri 'self', and img-src … https: (external BRAND_* logo/hero and <img> in sanitized wiki/news bodies rely on https:).

The soak is still worth running for that one directive, and arguably it is the directive that most needs one: a frame-ancestors report is generated by the browser of whoever framed the site, so it is the only way to discover that something legitimately embeds us before the enforcing policy breaks it. Nothing else can tell us that.

Rollout: ship via Content-Security-Policy-Report-Only with report-to for one release, watch for violations, then flip to enforce.

Before trusting script-src 'self': Vite's build injects an inline modulepreload-polyfill <script> into dist/index.html, which that directive blocks (harmless, but throws a violation). Already handledclient/vite.config.js sets modulePreload: { polyfill: false }, so the build emits no inline bootstrap script. (The renderIndexHtml branding injection adds only <meta>/<link> tags — no inline script, no nonce needed.)

Where reports go

report-to needs somewhere to point, so the report-only PR stands up a same-origin sink: POST /api/csp-report (server/src/router/cspReport.controller.js, wired in app.js). Same-origin on purpose — violation reports describe attacks against this site and are not handed to a third-party collector. It writes to the csp log tag and stores nothing.

It is mounted outside /api/v1, alongside /api/health: the browser learns the path from the policy header, never from a client build, so it is not part of the versioned client contract. This is the +1 in the route manifest that made PR 0 go first (see § Sequencing).

Necessary properties, since it is an unauthenticated public POST (browsers send reports with no session, and gating it would silence exactly the anonymous visitors worth hearing about):

  • Both wire formats. report-uri (Firefox, Safari) sends application/csp-report with a single hyphenated-key object; report-to (Chrome) sends application/reports+json with an array of camelCase envelopes. Handling one silently drops half the browsers. Both directives are emitted, and report-to additionally needs a Reporting-Endpoints response header or it is inert.
  • Always 204, even for junk. A 4xx would make the global error handler write an ERROR line quoting the attacker-supplied body — turning an open endpoint into a log-flood primitive. A browser cannot act on an error from a report sink anyway.
  • Bounded everywhere: 16 KB body cap, a per-IP rate limit, a fixed field allowlist, and every logged field truncated (script-sample is attacker-influenced and can carry a whole inline script).

Retiring it: the sink exists for the soak. When the tightened policy flips to enforced and the report-only twin is deleted, this endpoint goes with it — unless a report-to group is deliberately kept on the enforced policy, which is a reasonable thing to want. Decide that in the enforce PR rather than leaving an orphan route behind.

Tracked follow-ups (own PRs):

  • Self-host the Cinzel font → drop fonts.googleapis.com from style-src and fonts.gstatic.com from font-src, removing two third-party origins from the trust surface.
  • Trusted Typesrequire-trusted-types-for 'script' + a trusted-types policy, report-only first. Audit dangerouslySetInnerHTML + the sanitizeHtml render path first.

Phase 2 — The domain split

This is the reason the plan exists. Everything else is supporting work.

Rule: one router file = one business capability; the URL names the domain; related endpoints live together regardless of HTTP method; no generic admin.routes.js catch-all. Controllers are already domain-split — this re-wires routes, not logic.

Invariant: each capability router mounts at the prefix it already owns, so the emitted URL set does not change. Proved per PR by the route manifest (§ PR 0).

Target tree — derived from the inventory above, inside router/v1/ (no v2/ directory):

router/v1/
  admin/
    index.js            # mounts the capability routers below under /admin, keeps the
                        # shared `noindex, isLoggedIn, staffOnly` gate in one place
    users.router.js         account.router.js       invites.router.js
    authProviders.router.js moderation.router.js    botActivity.router.js
    posts.router.js         pages.router.js         wiki.router.js
    uploads.router.js       shard.router.js         uoLink.router.js
    email.router.js         discordBot.router.js    settings.router.js
    activity.router.js      # the staff audit log — landed in PR 2, not with dashboard
    dashboard.router.js     # + the /site-mode singleton
  auth/
    login.router.js  register.router.js  password.router.js  invite.router.js
    sso.router.js    mobile.router.js    me.routes.js (already split, 23 routes)
  public/
    news.router.js (posts) pages.router.js  wiki.router.js  shard.router.js
    site.router.js         # status, settings, version, contact
  player/
    account.router.js  shard.router.js  appeals.router.js
  internal/            (unchanged — stays on the unpublished port, never mounted publicly)

Steps:

  1. Land PR 0 (route manifest) first — the mechanical proof that later PRs move no URL.
  2. Carve admin.routes.js into the per-capability files above, each requiring its already-existing controller. admin/index.js keeps the shared gate (noindex, isLoggedIn, staffOnly) and mounts each capability router at its existing prefix; the adminOnly / modAccess gates move with the routes that use them.
  3. Split public.routes.js, player.routes.js, and the remaining auth.routes.js groups the same way. auth/me.routes.js is already a separate file and stays.
  4. Keep /internal off the public listener exactly as today (separate internalApp.js port).
  5. Move each route's #swagger.* annotations with the route, then regenerate (cd website/server && npm run swagger).
  6. Update BACKEND_DESIGN.md §2 (folder structure) and §4 (API contract — it names admin.routes.js → admin.controller.js and friends) as routers move, plus PROJECT_TREE.md.

Because the auth model is untouched and the URLs are frozen, each PR is a pure mechanical refactor with a green test suite and a zero-diff route manifest as its acceptance criteria — which is what makes grouped PRs actually reviewable.

Step 6 correction: PROJECT_TREE.md is no longer hand-edited. Since website#98 it is auto-generated by the sync-project-tree CI workflow, which opens its own docs PR after a merge to main. Leave it alone in split PRs. BACKEND_DESIGN.md §2/§4 are still manual.

PR 1 — as landed

admin/index.js owns the shared noindex, isLoggedIn, staffOnly gate and the mount table, and declares no routes itself. The gate sits ahead of every mount, so a capability router extracted in a later PR cannot silently ship without it. Route counts:

Router Routes Prefix Extra gate
account.router.js 6 /admin/account none — self-service, an editor manages their own 2FA
users.router.js 15 /admin/users adminOnly at router level
invites.router.js 3 /admin/invites adminOnly per route
authProviders.router.js 4 /admin/auth (routes are /providers[/:id]) adminOnly per route
admin.routes.js (residual) 82 group root, mounted last unchanged

6 + 15 + 3 + 4 + 82 = the 110 inventoried admin routes. None of the four prefixes appears in the residual file, so nothing depends on mount ordering.

Three findings worth carrying into PRs 25:

  • The self-service /shard/* routes stay with shard (PR 4), despite their Admin · Account swagger tag. The invariant is prefix ownership, not tag agreement: one router owns /shard, so splitting six routes off it by capability would mean two routers mounting under the same prefix and an ordering hazard for no gain. Retag them in PR 4 if the tag still grates.
  • usersRouter.use(adminOnly) is exactly equivalent to the old adminRouter.use('/users', adminOnly) now that the router is mounted at /users — but only because it is mounted at a prefix. Under a pathless mount, a bare use(gate) would run for every request passing through en route to a later mount, 403-ing an editor on /admin/posts. Do not "simplify" a prefix mount away.
  • routes.guards.json came back zero-diff too, not just the manifest — no route lost or gained a gate. Worth checking both every time; the guards file is the one that would catch a dropped adminOnly that the method+path freeze cannot see.

The OpenAPI spec was also byte-for-byte unchanged, which required a prerequisite fix — see below.

PR 2 — as landed

moderation, bot-activity and activity — 18 routes, leaving 64 in the residual file.

Router Routes Prefix Extra gate
moderation.router.js 15 /admin/moderation modAccess (admin + moderator) at router level
botActivity.router.js 2 /admin/bot-activity adminOnly per route
activity.router.js 1 /admin/activity none — staff-wide audit log
admin.routes.js (residual) 64 group root, mounted last unchanged

All four gates came back 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 server tests green.

Notes:

  • /activity gets its own file, deviating from the target tree above, which parked it as a singleton inside dashboard.router.js. It is folded into PR 2 by the sequencing list, and PR 4 is where dashboard lands — so honouring the tree would have meant leaving one route in the residual file for two PRs to satisfy a filename. It is also a genuinely separate capability: /activity is the staff audit log (activity.model.js), while /dashboard is a stats overview and /bot-activity is the botScore middleware's in-memory ban state. Three different things that read alike. PR 4 mounts dashboard and site-mode only.
  • modAccess moved to a router-level use; adminOnly on bot-activity deliberately did not. Moderation was already gated by a prefix mount (adminRouter.use('/moderation', modAccess)), so moderationRouter.use(modAccess) is the exact equivalent (the PR 1 users case). Bot-activity's gate was per-route, and keeping it per-route is what holds the per-route handler count — the one number in routes.guards.json that would catch a dropped adminOnly, since requireRole(...) returns an anonymous arrow and never shows up by name. Rule for PRs 35: move a gate to router level only where it was already a prefix mount; otherwise leave it on the route.
  • modAccess stays in the residual file — the /shard/* in-game staff operations still use it and do not move until PR 4. Its comment there was retargeted rather than deleted.

PR 3 — as landed

posts, uploads, wiki and pages — 31 routes, leaving 33 in the residual file. The content tier, and the first split PR where no gate moved at all: all four capabilities are editor-tier, so the shared staffOnly in admin/index.js is their whole gate.

Router Routes Prefix Extra gate
posts.router.js 9 /admin/posts none — editor tier
uploads.router.js 1 /admin/uploads none — editor tier
wiki.router.js 14 /admin/wiki none — editor tier
pages.router.js 7 /admin/pages none — editor tier
admin.routes.js (residual) 33 group root, mounted last unchanged

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 server tests green.

Notes:

  • The residual 33 is exactly PR 4's listshard 16, email 6, uo-link 5, settings 2, discord-bot 2, dashboard 1, site-mode 1. So admin.routes.js is deleted by PR 4, one PR earlier than the sequencing list implies, and PR 5 touches only public/*, player/* and auth/*.
  • A shared module was unavoidable here, and it is the first one in the split. The multer config (upload dir, mimetype→extension allowlist, 8 MB cap) was defined inline in admin.routes.js and used by two routes that this PR puts in different files: POST /posts/upload (→ {image_url}) and POST /uploads (→ {url}). It moved to admin/imageUpload.js rather than being duplicated — duplicating a security allowlist is how the two copies drift. It stays in admin/ deliberately: UPLOAD_DIR is resolved __dirname-relative, so relocating the file would silently repoint the upload directory. Guard freshness is unaffected — multer's middleware is named multerMiddleware wherever it is constructed, so routes.guards.json did not move.
  • POST /uploads keeps its Admin · Posts swagger tag, which now disagrees with its filename. The acceptance criterion is a byte-identical spec, so retagging is a real OpenAPI diff and does not belong in a route-move PR. Same call as PR 1's /shard/* tag mismatch: fix tags in a PR that is about tags.
  • The wiki router is the first one with load-bearing intra-file route order. /categories and /tags are literal paths that must stay ahead of /:slug, or GET /admin/wiki/categories gets dispatched as a page whose slug is "categories". The manifest cannot catch this — it sorts its entries, so a reordering is invisible in all three gates. It was verified separately by introspecting the built router stack and asserting the last literal layer precedes the first /:slug layer. Any future PR moving /:slug-style routes needs the same explicit check.
  • /admin/pages (CMS page builder) and /admin/shard/pages (in-game help-page queue) are unrelated capabilities that read alike — the latter stays with shard in PR 4. Same trap as PR 2's activity / dashboard / bot-activity trio.

PR 4 — as landed

shard, uo-link, email, discord-bot, settings and dashboard/site-mode — the whole residual 33. admin.routes.js is deleted, so the admin group is fully split and every one of its 110 routes is declared in a capability router.

Router Routes Prefix Extra gate
shard.router.js 16 /admin/shard none on the 7 self-service routes; modAccess per route on the 9 staff ops
uoLink.router.js 5 /admin/uo-link adminOnly per route
email.router.js 6 /admin/email adminOnly per route
discordBot.router.js 2 /admin/discord-bot adminOnly per route
settings.router.js 2 /admin/settings adminOnly per route
dashboard.router.js 2 group root (/dashboard, /site-mode) adminOnly per route on /site-mode only
admin.routes.js deleted

16 + 5 + 6 + 2 + 2 + 2 = 33. 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 server tests green.

Notes:

  • dashboard.router.js is mounted at the group root, not a prefix — the one relaxation of the "always mount at a prefix" rule, and it is deliberate. GET /dashboard and PUT /site-mode own no common path segment, so a prefix mount would mean two one-route files instead of the single file the target tree calls for. It is safe only because the file declares no router-level middleware: a bare use(gate) in a root-mounted router runs for every request passing through toward another mount and would 403 an editor on an unrelated route (the PR 1 finding). The file says so in a comment, because the next person to add a gate there is the one who needs to know.
  • /shard is the first prefix where two tiers share one router, and it is why prefix ownership beats swagger-tag grouping. The 7 self-service routes (link, accounts, roster/:account, vendors/:account, char/:serial, sales, POST account) are tagged Admin · Account, run with no gate beyond the shared staffOnly, and are served by the very same player/shard.controller handlers as /player/shard — staff are a superset of players, and the controller keys off req.user.id. The 9 in-game ops are tagged Admin · Shard and carry modAccess. Splitting them by tag would put two routers under one prefix for no gain; instead one router owns /shard and gates per route. The tag mismatch stays, on the PR 1 and PR 3 precedent: retagging is a real spec diff and belongs in a PR that is about tags.
  • No gate moved to router level anywhere in this PR. Every adminOnly in the residual file was per-route, and modAccess on /shard must stay per-route because half that router must not have it. This keeps the per-route handler count intact — the one number routes.guards.json can actually check, since requireRole(...) returns an anonymous arrow.
  • The /:param shadowing check was run again and is clean, since the manifest sorts and therefore cannot see declaration order. Introspecting the built stack, all 110 admin routes and all 59 literal admin paths dispatch to their own layer — nothing is captured first by a :param sibling. The near-misses worth naming: GET /shard/pages (help-page queue) sits alongside POST /shard/pages/:id/respond|close, and POST /shard/towncrier alongside DELETE /uo-link/towncrier/:id — different depths and methods, so neither collides.
  • Deleting the file left dangling see admin.routes.js pointers, which were repointed in the same PR: botActivity.controller.jsbotActivity.router.js, moderation.controller.jsmoderation.router.js, announceJobs.logic.js's town-crier cap mirror → admin/uoLink.router.js, and the "route paths sit on the line after adminRouter.get(" rationale in scripts/routeManifest.js, README.md and pr-checks.yml was generalized (it was never about that one file).
  • /admin/shard/pages vs /admin/pages stayed separate, as PR 3 flagged: the former is the in-game help-page (support) queue and belongs to shard; the latter is the CMS page builder.

PR 5 — as landed

public, player and the residual auth — 54 routes across three groups, the last split PR. public.routes.js, player.routes.js and auth.routes.js are all deleted, so every one of the 200 routes in the manifest is now declared in a capability router and no monolithic route file remains anywhere 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. 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 server tests green.

Notes:

  • Each group is now a directory with an index.js, matching admin/: public/index.js, player/index.js, auth/index.js own the group gate (where there is one) and the mount table and declare no routes. v1.router.js requires the directories. The four tests that imported the deleted entry files were repointed.
  • 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, and it is the one genuinely non-obvious thing in this PR. authRouter.use('/me', meRouter) matches the bare path /me, not just /me/* — so a request to GET /auth/me 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. No gate file and 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 (/settings, /status, /version, /contact) and auth/session.router.js (/logout, /me) hold the routes that own no path segment. Both are safe at the root only because they declare no router-level middleware — a bare use(gate) there runs for every request passing through toward another mount. Both files say so.
  • loginGuards is the PR's one shared module, the counterpart to PR 3's imageUpload.js. The [backoffGuard, slowLogin, loginLimiter] array was defined inline in auth.routes.js and spread by four routes that this PR puts in three different files — plus a fifth, already-duplicated copy in sso.routes.js. It moved to auth/loginGuards.js and sso.routes.js now imports it too, so there is one definition rather than five: duplicating a throttling stack is how the copies drift, and the copy that drifts is the one that stops throttling. It is exported Object.freezed — it is module-level shared state, and a router that pushed onto it would silently add middleware to every other login surface. Guard freshness is unaffected: the same three named functions, so routes.guards.json did not move.
  • The /:param shadowing check was run again and is clean. All 86 public/player/auth routes and all 64 literal paths among them dispatch to their own layer. This was checked in dispatch order against the built stack, since the manifest sorts and therefore cannot see declaration order. 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, and wiki.router.js says so. The /public/pages preview route also stays ahead of /:slug, though at a different depth.
  • Filename deviations from the target tree, both for prefix agreement. The tree named the public posts router news.router.js; it is posts.router.js, matching the /posts prefix it owns and its admin/posts.router.js sibling. The tree also implied sso.router.js / mobile.router.js; those files already exist as sso.routes.js / mobile.routes.js and were not renamed — they did not move in this PR, and churning their names would add diff noise to a PR whose value is being reviewable.
  • auth/session.router.js is a deviation the target tree did not anticipate, the same shape as PR 2's activity.router.js. The tree listed login.router.js but had nowhere to put /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; a separate root-mounted singleton file keeps /login a real prefix mount.
  • Tag mismatches were left alone again, on the PR 1 / PR 3 / PR 4 precedent: the acceptance criterion is a byte-identical spec, so retagging belongs in a PR that is about tags.
  • public.controller.js was not split. Unlike the admin controllers, it is still one file serving settings/status/version/contact and posts/wiki/pages. The plan's rule is that these PRs re-wire routes, not logic — splitting a controller is a separate change with a separate risk profile, and bundling it would have cost this PR its "pure mechanical refactor" acceptance criteria.

The swagger path-normalization prerequisite (landed before PR 1)

swagger-autogen builds a path by string-concatenating the mount prefix with the route argument, so a capability router mounted at /users whose collection route is router.get('/') documents as /api/v1/admin/users/ — advertising a URL no client calls while dropping the one the SPA, the Android app and the Discord bot all do. Express is indifferent; the published spec is not. It also emits path keys in router-traversal order, so moving a route between files rewrote most of the ~5k-line committed artifact even when the API was provably unchanged — burying the one line a reviewer needs.

Both are fixed once in server/swagger/swagger.js, which post-processes the generator's output to strip trailing slashes and sort path keys (throwing on a collision rather than silently dropping an operation). It shipped as its own PR ahead of PR 1, verified inert by the regenerated spec being byte-for-byte the sorted form of the previously committed one — 198 operations, none added or removed. #swagger.path was rejected as the fix: it bypasses the mount prefix, so every route would hardcode its absolute path in a comment that silently lies the moment a mount moves.

Consequence for PRs 25: the swagger diff is now a signal. With sorting in place, a pure route move produces no spec diff at all, so any diff there means an annotation actually changed. Treat swagger-output.json, routes.manifest.json and routes.guards.json as three zero-diff gates.

PR 0 — the route manifest (prerequisite of the first split PR) — landed

Status: shipped. server/scripts/routeManifest.js + npm run routes:manifest, server/routes.manifest.json (199 public + 2 internal), server/routes.guards.json, server/test/routeManifest.test.js, and a routes:manifest -- --check step in .gitea/workflows/pr-checks.yml. No router file moved. The generator reproduced api-route-inventory.json byte-for-byte on first run, so the freeze is in effect and the committed baseline is confirmed accurate rather than merely asserted.

Two deviations from the design below, both deliberate:

  • The unauthenticated-status snapshot was tried and dropped, exactly as this section allowed. Firing unauthenticated GETs at every manifest path against the dead-port mariadb pool the tests use does not fail fast — the pool sits on its acquire timeout, and a partial sweep had not finished after two minutes. A flaky two-minute gate is worse than none. What replaced it is cheap and deterministic: the test suite asserts from the introspected stack that every /api/v1/admin/** and /api/v1/player/** route still carries requireAuth.
  • routes.guards.json is committed and staleness-checked, though a diff in it is explicitly not a contract change. Left ungenerated it would rot into a misleading review aid within a release. The gate is on freshness; the meaning of a guards diff is still "read this", not "justify this". The generator drops app-level plumbing (helmet, morgan, the JSON parser, the bot guard) since it applies uniformly to all 199 routes and would bury the per-route gates.

"Every URL is unchanged" must be proved by a diff, not asserted in review. PR 0 lands the tool that proves it, with no router file moved.

  • Generator: server/scripts/routeManifest.js, wired as npm run routes:manifest. It requires src/app.js (which exports the app and neither listens nor connects to the DB — server.js owns those), walks app._router.stack recursively through mounted routers, reconstructs each full path from the layer regexps, and writes a sorted array of { "method": "GET", "path": "/api/v1/admin/users/:id" } to server/routes.manifest.json. internalApp.js is walked into a separate internal section so the unpublished port is inventoried without being confused for public surface.
  • Scope it to the API surface, or it won't be deterministic. Three mounts are filesystem conditional: the SPA catch-all GET * (only when client/dist/index.html exists), the /brand static mount, and /api/docs* (only when swagger-output.json is present — it is committed, so it is stable). The manifest keeps only /api/**, /.well-known/**, and the internal app's routes, so it does not change depending on whether CI built the client. Static mounts are not API contract.
  • The baseline already exists: api-route-inventory.json in this directory is the frozen surface — 199 API routes (110 of them /api/v1/admin) plus 2 internal at the time PR 0 was written. PR 0's generator must reproduce this file byte-for-byte; that is PR 0's own acceptance test, and it means the freeze is already in effect before the first router moves. (It did, on first run. The file has since moved to 200 — the CSP report-only PR added POST /api/csp-report, the first deliberate, reviewed manifest diff.)
  • Runtime introspection, not source parsing. It is authoritative about mounts, and the route paths in admin.routes.js sit on the line after adminRouter.get(, which defeats naive greps.
  • Not swagger-output.json. That is annotation-derived (only annotated routes appear) and churns for unrelated reasons; it documents intent, the manifest records reality.
  • Frozen key: method + path only. That is exactly the contract being preserved. Handler names are useless as a guard check here — requireRole(...) returns an anonymous arrow, and router-level gates like adminRouter.use(noindex, isLoggedIn, staffOnly) never appear in a route's own stack.
  • Guard coverage, separately. The generator also emits a non-gated review aid: per route, the handler count plus any named middleware collected along the mount chain. If a stable behavioral check proves cheap, prefer it — a test that fires an unauthenticated request at every manifest path and snapshots the status code catches a dropped adminOnly (403 → 200) in a way names cannot. Try it in PR 0; if DB-touching public routes make it slow or noisy against the dead-port pool the tests use, drop it rather than ship a flaky gate.
  • CI: .gitea/workflows/pr-checks.yml runs npm run routes:manifest and git diff --exit-code server/routes.manifest.json. A PR that moves a URL fails unless it deliberately commits the new manifest — which puts the URL change in front of a reviewer instead of letting it pass silently.
  • Published copy: docs/website/api-route-inventory.json mirrors server/routes.manifest.json and is refreshed in each split PR's mandatory docs edit. The markdown table above is orientation for a human reader; the manifest is the authoritative freeze.

Sequencing & PR breakdown

CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.

Resequenced during implementation: PR 0 ships first, before the CSP pair. The CSP report-only PR has to stand up a violation collector (POST /api/csp-report) for report-to to point at — which is a new URL under /api/**. Landing it first would mean PR 0's generator emitting 200 routes against a 199-route committed baseline, so PR 0 could no longer prove itself by reproducing api-route-inventory.json byte-for-byte. With PR 0 first, the collector shows up as a reviewed, deliberate +1 in the manifest — which is exactly the mechanism working as designed.

  1. PR 0 — route manifest. Generator + CI check + committed baseline of today's surface. No routers moved. landed
  2. PR — CSP report-only. Tightened policy behind Content-Security-Policy-Report-Only + report-to, plus the report collector (manifest +1 — the first deliberate, reviewed manifest diff). landed
  3. PR — CSP enforce. One release later, assuming a clean violation report. Blocked on real soak data, not on code: watch the csp log tag for frame-ancestors reports across one release before flipping. Also decide there whether /api/csp-report is retired with the report-only twin or kept as a report-to group on the enforced policy.
  4. PR 1 — admin: users, account, invites, auth (providers). landed
  5. PR 2 — admin: moderation, bot-activity, activity. landed
  6. PR 3 — admin (content): posts, pages, wiki, uploads. landed
  7. PR 4 — admin (ops/config): shard, uo-link, email, discord-bot, settings, site-mode, dashboard. (activity went with PR 2 — see § PR 2 — as landed.) This is the whole residual fileadmin.routes.js is deleted here, not by PR 5. landed
  8. PR 5 — public/* + player/* (and the residual auth/* grouping). landed — the domain split is complete. The only remaining item in this plan is the CSP enforce PR (3), which is blocked on soak data, not on code.

Each PR: zero-line diff in routes.manifest.json, server tests green (cd website/server && npm test), Swagger regenerated, matching docs/ edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled main.


Cross-component blast radius

Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate release cadences. With the auth merge and the version bump both gone, the blast radius is empty — no client's URLs or authentication change at all.

Consumer Repo (cadence) Pinning Impact under this plan
Browser SPA website/client (lockstep) BASE = /api/v1 in client/src/api/client.js None. Same URLs, same cookie session.
Android app android-app (app-store cadence, un-updatable installs in the wild) 69 hardcoded api/v1/… paths; SSE path in ShardStreamClient.kt; SSO in SsoAuthManager.kt None. No repoint, no release required.
Discord bot website/bot (separate deploy, env-configured) SITE_PUBLIC_URL env → /api/v1/public None. Anonymous public reads on unchanged paths.

Standing constraints, unchanged:

  • The public SSE stream stays anonymous. Consumed by logged-out browser visitors and the Android ShardStreamClient, neither of which sends an Authorization header. Adding requireAuth blacks out the public live boards on web and mobile. The single most likely regression in a careless refactor is reflexively wrapping both shard streams in auth.
  • The admin SSE stream keeps its current cookie-based gating (isLoggedIn, EventSource + withCredentials) — the rewrite that would have changed this went out with the auth merge.
  • The public/admin allowlist split is a security boundary, not an implementation detail. Preserve it verbatim in utils/shardBroadcast.js / utils/shardIngest.js as routes move.
  • SSO / email-connect transaction cookies are load-bearing for web and native. The Android SSO flow opens a Custom Tab to the website's /auth/…/sso/start and rides the same server-side redirect transaction and the same tx cookies. Nothing in this plan touches them; don't let a future "cookies go away" push delete them.
  • link/ is out of scope. The sidecar contract (uoLinkConfig, utils/uoLinkClient.js, utils/shardIngest.js, X-UOLink-Version) is a separate versioning axis. PROTOCOL_VERSION does not bump for this work.

Appendix: mapping from the previous plan

Previous Now
Phase 0 — /api/mobile facade + app-version floor Deferred. See § Deferred: the /api/mobile facade
Phase 1 — auth merge Removed. See § Why the auth merge is out and § Deferred: the auth merge
Phase 1b — CSP hardening (shipped with the auth merge) Phase 1, standalone; report-only-first ordering fixed
Phase 2 — domain split under router/v2/ Phase 2, in place under router/v1/; now the primary driver
PR 1 — /api/v2 scaffold (API_V2_SKELETON.md) Superseded, kept as the recipe if a versioned API is ever forced
PR final — retire v1 Not applicable — v1 is never replaced