# 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](./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 split** — `admin.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`](./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](./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 `
` 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 `` 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 `