# Website API v2 — Plan Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md` v2 is three sequenced pieces of work, landed in order: 0. **The mobile facade** (lands *first*, before any v2 work) — a **version-agnostic `/api/mobile` namespace** is stood up over the current controllers, and the Android app is migrated to it. From then on the app is pinned to `/api/mobile`, insulated from all internal version churn — so the auth merge and domain split below never touch it. See Phase 0. 1. **The auth merge** — httpOnly session cookies go away; a bearer JWT (access) + rotating refresh token becomes the single session model for *every* client (web and mobile). 2. **The domain split** — the monolithic route wiring (esp. `admin.routes.js`, 1552 lines) is broken into one router file per business capability, so a developer can predict where an endpoint lives from its URL. ## Locked decisions | Decision | Choice | Consequence | |---|---|---| | Mobile decoupling | **A version-agnostic `/api/mobile` facade, landed before v2** | The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely. | | Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the *web* client route-by-route; delete v1 once no caller remains. No big-bang break. | | Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. | | Live-feed (SSE) auth | **admin stream → fetch + `Authorization: Bearer`; public stream stays anonymous** | Admin token stays out of URLs/logs. Public/mobile stream keeps `EventSource`, no auth header. | ## What already exists (so we don't rebuild it) - `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and `auth/session.service.js` already unifies both into one session. - Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` / `refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side. Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`. - **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the unified flow; the app sees no change. See Phase 0.) --- ## Phase 0 — The mobile facade (`/api/mobile`, lands before v2) **Goal:** make the Android app's contract *version-agnostic* so the v2 work below can proceed without ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE path and SSO URLs) and has **no version negotiation and no force-update** — which is exactly why v1 can't otherwise be retired on the web client's schedule. Phase 0 pays that migration **once**, up front, against a pure rename with no behavior change, and never again. **What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional response-shaping*, never business logic — a Backend-for-Frontend, not a fork. ### Server 1. **Mount `/api/mobile` in `api.router.js`** next to `/v1` (today it mounts only `/v1`). 2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls mostly *shared* routes (`auth/me/*`, `public/*`, `player/*`, `admin/*`) plus the mobile-only auth routes and the anonymous `public/shard/stream`. All of them move under `/api/mobile/**`. If any endpoint the app needs is left only under `/api/v1`, the app is not decoupled and the whole point is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius). 3. **Delegate; never bypass auth.** Each facade route requires the *same* middleware chain as its underlying route (`requireAuth`, `staffOnly`/`adminOnly`, validators, the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is a privilege-escalation hole — treat the facade as a security surface, not a convenience alias. 4. **Keep the mobile SSE stream anonymous.** `/api/mobile/…/shard/stream` carries no auth (the app sends no `Authorization` header); only the public/safe kinds, same allowlist as today. 5. **Pin the wire shapes with contract tests.** `/api/mobile` is now a **committed stable contract**: a v2 internal refactor that changes a response shape must fail a test here *before* it can ship to installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI. ### Android app (`Android-app` repo — separate PR, separate release) 6. **Re-point everything to `/api/mobile`:** the ~70 Retrofit endpoints (`data/api/*.kt`), the SSE path in `ShardStreamClient.kt`, and the SSO start/exchange URLs in `SsoAuthManager.kt`. Drop the `api/v1/` and `auth/mobile/` prefixes; the app now knows only `/api/mobile`. 7. **Add the app-version header + a server-side min-version floor** (the mechanism the blast-radius section calls for). Its first job is to sunset the *pre-facade* app so `/api/v1` can eventually be deleted; thereafter it is insurance for any genuinely breaking `/api/mobile` change (negotiated in-band, since URL-path versioning is deliberately gone here). 8. **Ship and let the fleet adopt** before starting v2. The old app keeps working on frozen `/api/v1` until the version floor ages it out. ### Docs / spec 9. Document `/api/mobile` as its own tagged surface in Swagger; record the facade + the version-floor mechanism in `docs/android/PLAN.md`, and note the app-contract change in the Android repo's docs. **Not in scope for Phase 0:** any behavior change, any auth-model change, any v2 route. The facade maps 1:1 onto today's controllers; the auth merge happens later and reaches the app only as an internal re-point behind the unchanged `/api/mobile` shapes. --- ## Phase 1 — The auth merge (cookie removal) ### Server 1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`: - `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`). - `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair. - `POST /logout` → revoke the current refresh/session server-side. - `POST /login/totp` → second-factor step, same token shape. The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these. 2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` / `clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2 routes (v1 keeps it until v1 is removed). 3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email- connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP and returns with no JS context to carry a bearer across the hop. Only the **final session** handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`. 4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients (`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is dropped for v2. 5. **SSE auth is per-channel — the public stream stays anonymous.** The **admin** shard stream (today gated by the session cookie via `isLoggedIn`) moves to `requireAuth` on the Bearer header, since its browser client becomes fetch-based (below). The **public** shard stream (`/public/shard/stream`) has **no auth middleware today and must keep none** — it is consumed by logged-out browser visitors *and* by the Android `ShardStreamClient`, neither of which sends an `Authorization` header. Adding `requireAuth` to it would black out the public live boards on web and mobile. Keep the public/admin allowlist split — that security boundary is unchanged. ### Client 6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer `; on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login. 7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`. Short access TTL keeps the XSS window small — the accepted tradeoff for losing httpOnly. 8. **`lib/useShardFeed.js`:** only the **admin** stream needs the rewrite — replace `EventSource(adminShardStreamUrl, { withCredentials: true })` with a `fetch()` + `ReadableStream` reader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff + access-token refresh-on-401. The **public** stream stays on `EventSource` (no credentials, so nothing to change) and keeps its free auto-reconnect. Do not convert both blindly. ### Docs / spec (required, same PR) 9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception). 10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme, remove `Set-Cookie` from documented responses. --- ## Phase 1b — CSP hardening (ships with the auth merge) Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't run, and if it somehow runs it can't phone home.** The app already ships a tuned policy (`server/src/app.js`); v2 tightens it rather than rewriting it. Two directives are load-bearing for the token-theft threat and must stay tight: - `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it. - `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host) unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here. `style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole. 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'; ``` Changes vs. the current policy: - **Add `form-action 'self'`** — blocks an injected `
` from POSTing the token/credentials off-origin (an exfil path `connect-src` doesn't cover). - **Tighten `frame-ancestors` `'self'` → `'none'`** — nothing legitimately frames the site. - Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*` logo/hero and `` in sanitized wiki/news bodies rely on `https:`). Tracked follow-ups (own PRs, not blocking the merge): - **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 Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in **report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first. - **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for violations, then flip to enforce. Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `