Files
docs/website/API_V2_PLAN.md
wtclaude 5de5e19445 docs(website): finalize the API plan — in-place router split, no /api/v2
The API v2 plan is revised down to the work that is actually justified: a CSP
hardening pass and an in-place domain split of the monolithic route wiring.

- Auth merge (httpOnly cookies -> bearer + rotating refresh for every client) is
  removed and re-filed as deferred behind trigger conditions. httpOnly+SameSite
  is the stronger model, session.service.js already unifies cookie and bearer,
  the SSO/PKCE transaction cookies survive any merge, and it dragged the admin
  SSE fetch/ReadableStream rewrite along as a dependency for no user-visible
  payoff. A revival must first spec refresh-token reuse detection and a rollback
  procedure.
- No parallel /api/v2. The URL surface is already grouped by capability, so each
  new router file mounts at the prefix it already owns and every URL stays
  byte-identical. No dual mount, no per-route migration, no v1 retirement; the
  SPA, Discord bot, and Android app are all untouched. API_V2_SKELETON.md is
  marked superseded (kept as the recipe if a versioned API is ever forced).
- The /api/mobile facade and app-version floor are deferred with the revival note
  that it starts as a one-line alias mount, not ~70 hand-written delegates. The
  M11 milestone is dropped from android/PLAN.md.
- Adds PR 0: a generated route manifest, so "every URL is unchanged" is proved by
  a zero-line diff rather than asserted in review. The baseline
  api-route-inventory.json (199 API routes + 2 internal) is committed here and is
  what PR 0's generator must reproduce byte-for-byte.
- Split sequenced as five grouped PRs; CSP fixed to report-only first, then
  enforce (the old plan contradicted itself), with the verified delta being just
  form-action 'self' and frame-ancestors 'none'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:31:26 -05:00

22 KiB

Website API — router domain split + CSP hardening

Status: planning · 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 — the whole change is two directives:

  • Add form-action 'self' (currently absent) — blocks an injected <form action="https://evil"> from POSTing credentials off-origin, an exfil path connect-src doesn't cover.
  • Tighten frame-ancestors 'self''none' — nothing legitimately frames the site.
  • 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:).

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). Confirm it's disabled or set build.modulepreload.polyfill = false in the Vite config. (The renderIndexHtml branding injection adds only <meta>/<link> tags — no inline script, no nonce needed.)

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
    dashboard.router.js     # + the /activity and /site-mode singletons
  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.

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

"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 today's frozen surface — 199 API routes (110 of them /api/v1/admin) plus 2 internal. 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.
  • 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.

  1. PR — CSP report-only. Tightened policy behind Content-Security-Policy-Report-Only + report-to.
  2. PR — CSP enforce. One release later, assuming a clean violation report.
  3. PR 0 — route manifest. Generator + CI check + committed baseline of today's surface. No routers moved.
  4. PR 1 — admin: users, account, invites, auth (providers).
  5. PR 2 — admin: moderation, bot-activity, activity.
  6. PR 3 — admin (content): posts, pages, wiki, uploads.
  7. PR 4 — admin (ops/config): shard, uo-link, email, discord-bot, settings, site-mode, dashboard.
  8. PR 5 — public/* + player/* (and the residual auth/* grouping).

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