docs(website): add API v2 plan (auth merge, CSP hardening, domain split) #45

Merged
whitlocktech merged 1 commits from docs/api-v2-plan into main 2026-07-22 21:52:05 +00:00

193
website/API_V2_PLAN.md Normal file
View File

@@ -0,0 +1,193 @@
# 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 <access>`;
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 `<form action="https://evil">` 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 `<img>` 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
`<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.)
---
## Phase 2 — The domain split
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
live together regardless of HTTP method; no generic `admin.router.js` / `api.router.js` catch-alls.
Controllers are **already** domain-split — Phase 2 is mostly re-wiring the routes, not the logic.
Target tree (`router/v2/`):
```
router/v2/
admin/
dashboard.router.js users.router.js moderation.router.js
content.router.js wiki.router.js shard.router.js
settings.router.js invites.router.js bot-activity.router.js
auth/
login.router.js sso.router.js totp.router.js session.router.js
public/
news.router.js wiki.router.js page.router.js shard.router.js
player/
profile.router.js appeals.router.js shard.router.js
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
```
Steps:
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
12. Split `public.routes.js` and `player.routes.js` the same way.
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
---
## Sequencing & PR breakdown
1. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
but wired; no behavior change.
2. **PR 2 — auth merge (server):** v2 bearer auth routes + SSO callback code-exchange + SSE-on-Bearer
+ docs/swagger.
3. **PR 3 — auth merge (client):** `client.js` bearer + silent-refresh; `useShardFeed.js` fetch stream.
4. **PR 4…N — domain split:** one PR per admin capability (dashboard, users, moderation, content,
wiki, shard, settings, …) to keep diffs reviewable; then public + player.
5. **PR final — retire v1:** once the client is fully on v2 and no caller remains, delete `router/v1`
and the dead cookie code in `token.js`.
Each PR: server tests green (`cd website/server && npm test`), Swagger regenerated, matching `docs/`
edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `main`.
## Risks / watch-items
- **XSS is now token-theft.** Losing httpOnly means any XSS can read the access token. Mitigation:
short access TTL + refresh rotation + revocation, paired with the Phase 1b CSP hardening.
- **SSO/email tx cookies cannot be removed** — don't let "cookies go away" over-reach into the
redirect transaction. Only the session handoff changes.
- **SSE reconnect regressions** — `EventSource` gave auto-reconnect + `Last-Event-ID` for free; the
fetch reader must reproduce backoff and (if used) event-id resume, plus refresh a stale token
mid-stream.
- **Double maintenance while v1 and v2 coexist** — bug fixes may need both. Keep the window short;
drive the client migration to completion before adding new v1-only features.