Plan for website API v2, sequenced in two phases behind a parallel /api/v2: - Phase 1: retire httpOnly session cookies; unify web + mobile on the existing bearer access + rotating/revocable refresh model. Separates removable session cookies from the SSO/email transaction cookies that must stay. SSE moves to fetch-based streaming with Authorization: Bearer. - Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action 'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups. - Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into one router per business capability so the URL predicts the file. Co-Authored-By: Claude <noreply@anthropic.com>
194 lines
10 KiB
Markdown
194 lines
10 KiB
Markdown
# 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.
|