# Website API v2 — Plan Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md` v2 is two sequenced pieces of work, landed in order: 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 | |---|---|---| | Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the client route-by-route; delete v1 once nothing calls it. 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 | **fetch-based streaming with `Authorization: Bearer`** | Token stays out of URLs/logs. Client re-implements reconnect/backoff that `EventSource` gave for free. | ## 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 `/mobile` namespace disappears (it's just "auth" now), and the cookie code path is removed. --- ## 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 endpoints authenticate via `requireAuth` on the Bearer header.** Both the public and admin shard streams move under v2 and read the token from the header, since the client is now fetch-based (below). 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`:** replace `EventSource(..., { withCredentials: true })` with a `fetch()` + `ReadableStream` reader that sends the Bearer header and parses SSE frames; add reconnect/backoff + access-token refresh-on-401. ### 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 `