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>
10 KiB
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:
- 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).
- 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.jsalready extracts a token from cookie orAuthorization: Bearer, andauth/session.service.jsalready 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
/mobilenamespace disappears (it's just "auth" now), and the cookie code path is removed.
Phase 1 — The auth merge (cookie removal)
Server
- Promote the mobile flow to the mainline v2 auth routes. Under
/api/v2/auth:POST /login→ password (+ TOTP) → returns{ accessToken, refreshToken, user }(noSet-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.
- Delete the session-cookie code path in v2 controllers. Stop calling
setAuthCookie/clearAuthCookie.extractTokenkeeps its Bearer branch; the cookie branch is dead for v2 routes (v1 keeps it until v1 is removed). - 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 settingrg_token. - Trusted-device token already supports the
X-Trust-Tokenheader for native clients (extractTrustToken). Web switches to the same header + client storage;rg_trustcookie is dropped for v2. - SSE endpoints authenticate via
requireAuthon 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
api/client.js: dropcredentials: 'include'; attachAuthorization: Bearer <access>; on401, silent-refresh once via/auth/refresh, retry, else bounce to login.- 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. lib/useShardFeed.js: replaceEventSource(..., { withCredentials: true })with afetch()+ReadableStreamreader that sends the Bearer header and parses SSE frames; add reconnect/backoff + access-token refresh-on-401.
Docs / spec (required, same PR)
- Update
BACKEND_DESIGN.mdauth section (cookie → bearer-everywhere; tx-cookie exception). - Regenerate Swagger (
npm run swagger) — v2 auth routes,Authorizationsecurity scheme, removeSet-Cookiefrom 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 pathconnect-srcdoesn't cover). - Tighten
frame-ancestors'self'→'none'— nothing legitimately frames the site. - Keep
base-uri 'self',object-src 'none', andimg-src … https:(externalBRAND_*logo/hero and<img>in sanitized wiki/news bodies rely onhttps:).
Tracked follow-ups (own PRs, not blocking the merge):
- Self-host the Cinzel font → drop
fonts.googleapis.comfromstyle-srcandfonts.gstatic.comfromfont-src, removing two third-party origins from the trust surface. - Trusted Types — roll out
require-trusted-types-for 'script'+ atrusted-typespolicy in report-only first; neutralizes most DOM-based XSS at the sink (the exact bug class that would steal a JS-held token). AuditdangerouslySetInnerHTML+ thesanitizeHtmlrender path first. - Report-only rollout — ship the tightened policy via
Content-Security-Policy-Report-Onlywithreport-tofor 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:
- Carve
admin.routes.js(~100 routes) into the per-capability files above, each requiring its already-existing controller. A thinadmin/index.jsmounts them under/admin. - Split
public.routes.jsandplayer.routes.jsthe same way. v2.router.jsmounts the domain sub-routers;api.router.jsmounts/v1(frozen) and/v2.- Keep
/internaloff the public listener exactly as v1 does (separateinternalApp.jsport). - Update
#swagger.*annotations for every moved route, regenerate the spec, and updatePROJECT_TREE.md+BACKEND_DESIGN.mdroute map.
Sequencing & PR breakdown
- PR 1 — v2 scaffold:
router/v2/skeleton,v2.router.js, mount/v2next to/v1. Empty but wired; no behavior change. - PR 2 — auth merge (server): v2 bearer auth routes + SSO callback code-exchange + SSE-on-Bearer
- docs/swagger.
- PR 3 — auth merge (client):
client.jsbearer + silent-refresh;useShardFeed.jsfetch stream. - PR 4…N — domain split: one PR per admin capability (dashboard, users, moderation, content, wiki, shard, settings, …) to keep diffs reviewable; then public + player.
- PR final — retire v1: once the client is fully on v2 and no caller remains, delete
router/v1and the dead cookie code intoken.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 —
EventSourcegave auto-reconnect +Last-Event-IDfor 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.