Files
docs/website/API_V2_PLAN.md
wtclaude 2cb6f7a3b9 docs(website): add /api/v2 skeleton (PR 1 scaffold), link from plan
Companion doc to API_V2_PLAN.md describing PR 1: stand up router/v2/ empty but
wired next to a frozen /api/v1, with a trivial GET /api/v2/version to make the
mount testable and no behavior change. Includes the file tree, the api.router.js
/ v2.router.js wiring, empty capability-router stubs, and acceptance criteria.
Adds a forward link from the plan's PR-1 line to the skeleton doc.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:54:23 -05:00

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:

  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.

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

  1. api/client.js: drop credentials: 'include'; attach Authorization: Bearer <access>; on 401, silent-refresh once via /auth/refresh, retry, else bounce to login.
  2. 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.
  3. 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)

  1. Update BACKEND_DESIGN.md auth section (cookie → bearer-everywhere; tx-cookie exception).
  2. 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:

  1. 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.
  2. Split public.routes.js and player.routes.js the same way.
  3. v2.router.js mounts the domain sub-routers; api.router.js mounts /v1 (frozen) and /v2.
  4. Keep /internal off the public listener exactly as v1 does (separate internalApp.js port).
  5. 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. Detailed in API_V2_SKELETON.md.
  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 regressionsEventSource 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.