Files
docs/website/API_V2_PLAN.md
wtclaude 3eafaef97e docs(website): add cross-component blast-radius review + /api/mobile facade (Phase 0)
Expand the API v2 plan to account for consumers beyond the browser and
insulate the Android app from version churn before the v2 work begins.

- Inventory the three v1 API consumers (browser, Android app, Discord bot)
  and map the cross-component contracts (site<->link, site<->mobile).
- Fix two concrete plan bugs: the public shard SSE stream must stay
  anonymous (logged-out browsers and the app's ShardStreamClient send no
  auth header), and the useShardFeed fetch-rewrite is admin-stream-only.
- Add "Phase 0 - the mobile facade": a version-agnostic /api/mobile
  namespace (a thin BFF delegating to current controllers behind pinned
  wire shapes), landed before v2 so the auth merge never touches the app.
- Note link/ is essentially out of scope (no PROTOCOL_VERSION bump), with
  the admin-stream allowlist split as the only shared seam.
- Resequence PRs (Phase 0 first) and gate v1 retirement on the pre-facade
  app fleet aging out via an app-version floor, not the web client.
- Add M11 to docs/android/PLAN.md: migrate the app to /api/mobile + ship
  the app-version floor, cross-referenced with the website plan's Phase 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 23:54:15 -05:00

25 KiB

Website API v2 — Plan

Status: planning · Target repo: website/ · Docs owner: this file + BACKEND_DESIGN.md

v2 is three sequenced pieces of work, landed in order:

  1. The mobile facade (lands first, before any v2 work) — a version-agnostic /api/mobile namespace is stood up over the current controllers, and the Android app is migrated to it. From then on the app is pinned to /api/mobile, insulated from all internal version churn — so the auth merge and domain split below never touch it. See Phase 0.
  2. 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).
  3. 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
Mobile decoupling A version-agnostic /api/mobile facade, landed before v2 The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely.
Versioning New /api/v2 mounted in parallel with a frozen /api/v1 Migrate the web client route-by-route; delete v1 once no caller remains. 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 admin stream → fetch + Authorization: Bearer; public stream stays anonymous Admin token stays out of URLs/logs. Public/mobile stream keeps EventSource, no auth header.

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 internal /mobile auth namespace collapses into unified /auth/*, and the cookie code path is removed. (The app-facing /api/mobile facade is unaffected — it re-points its internals to the unified flow; the app sees no change. See Phase 0.)

Phase 0 — The mobile facade (/api/mobile, lands before v2)

Goal: make the Android app's contract version-agnostic so the v2 work below can proceed without ever breaking an installed app. The app currently hardcodes ~70 api/v1/… endpoints (plus the SSE path and SSO URLs) and has no version negotiation and no force-update — which is exactly why v1 can't otherwise be retired on the web client's schedule. Phase 0 pays that migration once, up front, against a pure rename with no behavior change, and never again.

What it is: a thin routing facade mounted at /api/mobile, next to /api/v1, that delegates to the same controllers with the same middleware as the routes it mirrors. It is routing + optional response-shaping, never business logic — a Backend-for-Frontend, not a fork.

Server

  1. Mount /api/mobile in api.router.js next to /v1 (today it mounts only /v1).
  2. Re-home the app's entire surface under it — not just the mobile-auth routes. The app calls mostly shared routes (auth/me/*, public/*, player/*, admin/*) plus the mobile-only auth routes and the anonymous public/shard/stream. All of them move under /api/mobile/**. If any endpoint the app needs is left only under /api/v1, the app is not decoupled and the whole point is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius).
  3. Delegate; never bypass auth. Each facade route requires the same middleware chain as its underlying route (requireAuth, staffOnly/adminOnly, validators, the public/admin SSE allowlist split). A re-exposed admin route missing adminOnly is a privilege-escalation hole — treat the facade as a security surface, not a convenience alias.
  4. Keep the mobile SSE stream anonymous. /api/mobile/…/shard/stream carries no auth (the app sends no Authorization header); only the public/safe kinds, same allowlist as today.
  5. Pin the wire shapes with contract tests. /api/mobile is now a committed stable contract: a v2 internal refactor that changes a response shape must fail a test here before it can ship to installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI.

Android app (Android-app repo — separate PR, separate release)

  1. Re-point everything to /api/mobile: the ~70 Retrofit endpoints (data/api/*.kt), the SSE path in ShardStreamClient.kt, and the SSO start/exchange URLs in SsoAuthManager.kt. Drop the api/v1/ and auth/mobile/ prefixes; the app now knows only /api/mobile.
  2. Add the app-version header + a server-side min-version floor (the mechanism the blast-radius section calls for). Its first job is to sunset the pre-facade app so /api/v1 can eventually be deleted; thereafter it is insurance for any genuinely breaking /api/mobile change (negotiated in-band, since URL-path versioning is deliberately gone here).
  3. Ship and let the fleet adopt before starting v2. The old app keeps working on frozen /api/v1 until the version floor ages it out.

Docs / spec

  1. Document /api/mobile as its own tagged surface in Swagger; record the facade + the version-floor mechanism in docs/android/PLAN.md, and note the app-contract change in the Android repo's docs.

Not in scope for Phase 0: any behavior change, any auth-model change, any v2 route. The facade maps 1:1 onto today's controllers; the auth merge happens later and reaches the app only as an internal re-point behind the unchanged /api/mobile shapes.


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 auth is per-channel — the public stream stays anonymous. The admin shard stream (today gated by the session cookie via isLoggedIn) moves to requireAuth on the Bearer header, since its browser client becomes fetch-based (below). The public shard stream (/public/shard/stream) has no auth middleware today and must keep none — it is consumed by logged-out browser visitors and by the Android ShardStreamClient, neither of which sends an Authorization header. Adding requireAuth to it would black out the public live boards on web and mobile. 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: only the admin stream needs the rewrite — replace EventSource(adminShardStreamUrl, { withCredentials: true }) with a fetch() + ReadableStream reader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff + access-token refresh-on-401. The public stream stays on EventSource (no credentials, so nothing to change) and keeps its free auto-reconnect. Do not convert both blindly.

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

Phase 0 lands entirely before the v2 scaffold — the app must be off /api/v1-direct and onto /api/mobile (and the fleet adopting) before v2 behavior work begins.

  1. PR 0a — mobile facade (server): mount /api/mobile in api.router.js; re-home the app's full endpoint surface as thin delegates to existing controllers (same middleware); add contract tests pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
  2. PR 0b — mobile migration (separate Android-app repo): re-point the ~70 Retrofit endpoints, the ShardStreamClient SSE path, and the SSO start/exchange URLs to /api/mobile; add the app-version header + server-side min-version floor. Released and adopted on the app-store cadence before v2 starts. See docs/android/PLAN.md.
  3. 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.
  4. PR 2 — auth merge (server): v2 bearer auth routes + SSO callback code-exchange + admin-SSE-on- Bearer + docs/swagger. Re-point /api/mobile's internals to the unified flow behind unchanged wire shapes (contract tests must stay green) — the app sees nothing.
  5. PR 3 — auth merge (web client): client.js bearer + silent-refresh; useShardFeed.js fetch stream for the admin stream only.
  6. PR 4…N — domain split: one PR per admin capability (dashboard, users, moderation, content, wiki, shard, settings, …) to keep diffs reviewable; then public + player. Keep /api/mobile delegating correctly as controllers move; contract tests catch any shape drift.
  7. PR final — retire v1: only once (a) the web client is fully on v2, and (b) telemetry shows no /api/v1 traffic from the pre-facade app, i.e. the old fleet has aged past the version floor. Then delete router/v1 and the dead cookie code in token.js. See § Cross-component blast radius.

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.

Cross-component blast radius

The plan above is written as if the website is the whole world. It isn't. Three independent clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release cadences. Any change to a URL shape, an auth mechanism, or a namespace is a contract change, not a local refactor — the same discipline the docs demand for the shard↔sidecar wire protocol applies here. This section is the coordination map the per-repo work must respect.

Who calls the site API

Consumer Repo (release cadence) How it's pinned to v1 Migration cost
Browser SPA website/client (same repo, lockstep) BASE = /api/v1 in client/src/api/client.js In-plan (Phase 1 client + Phase 2)
Android app Android-app (separate repo, app-store cadence, un-updatable installs in the wild) ~70 Retrofit endpoints hardcode api/v1/… across data/api/*.kt; SSE path hardcoded in ShardStreamClient.kt; SSO in SsoAuthManager.kt Migrated once to the version-agnostic /api/mobile facade in Phase 0; thereafter decoupled from v2.
Discord bot website/bot (separate deploy, but env-configured) SITE_PUBLIC_URL env → /api/v1/public (announce/wiki commands) Trivial — repoint the env var once /v2/public exists; unaffected by the auth merge (anonymous public reads)

Only the browser migrates in true lockstep. The bot is a one-line env change. The Android app was the load-bearing coupling — the /api/mobile facade (Phase 0) is what removes it from the critical path so v2 can proceed freely.

Mobile (site ↔ Android) — decoupled by the Phase 0 facade

The facade turns the mobile problem from "the app must chase every version rename forever" into "the app pins one stable namespace, migrated once." What remains to keep in view:

  1. The facade must be complete or the decoupling leaks. The app uses mostly shared routes (auth/me/*, public/*, player/*, admin/*), not just the mobile-auth ones. Every endpoint in the app's inventory (below) must exist under /api/mobile, or the app still calls /api/v1 directly and is not actually insulated. This is the single most important Phase 0 check.
  2. The one-time cutover is still fleet-gated — the facade doesn't erase that, it contains it. The pre-facade installed app still calls /api/v1/*, and with no force-update it does so until its users update. So /api/v1 stays alive until that old fleet ages out. The difference: this is now a pure-rename migration decoupled from v2 behavior, paid once, and closed out by the version floor (next point) rather than blocking the auth merge.
  3. The version floor is still needed — its role just changed. Add an app-version header + a server-side min-version gate (Phase 0 step 7). Its first job is to sunset the pre-facade app so /api/v1 can finally be deleted; afterward it is the in-band mechanism for any breaking /api/mobile change, since URL-path versioning is deliberately absent there. Without it there is still no safe way to force the last stragglers off v1.
  4. v1-usage telemetry — per-route counts, tagged to distinguish the migrated browser from lingering pre-facade app installs, so "nothing calls v1" is measured, not assumed.
  5. /api/mobile is now a frozen wire contract — contract tests (Phase 0 step 5) must guard its response shapes so a v2 internal refactor can't silently break installed apps. This replaces "don't break the app during the auth merge" with "the auth merge can't reach the app at all."
  6. The public/mobile SSE stream must stay anonymous (Phase 0 step 4 / Phase 1 step 5) — the app's ShardStreamClient sends no auth header.
  7. Record the mobile side in docs/android/PLAN.md. This plan owns the /api/mobile server contract + the facade; that plan owns the app migration + version-floor rollout.

The auth merge is a website↔client change; the website↔sidecar contract is orthogonal and should not move:

  • The sidecar's own bearer token lives in the DB (uoLinkConfig, write-only in the API), the server-side REST client (utils/uoLinkClient.js) and live ingest (utils/shardIngest.js) talk to the sidecar independent of any browser/mobile session, and PROTOCOL_VERSION / X-UOLink-Version are their own versioning axis. None of that changes for v2link/ needs no edit and PROTOCOL_VERSION does not bump for this work.
  • The one seam: the admin SSE stream re-fans the sidecar's full feed (including sensitive kinds). When that endpoint moves to Bearer auth under v2, the public/admin allowlist split in the fan-out (utils/shardBroadcast.js / shardIngest.js) is the same security boundary it is today and must be preserved verbatim. This is a website-internal concern; it does not reach into link/.

SSO transaction cookies are load-bearing for both web and native

Phase 1 step 3's carve-out (keep the short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies) isn't only a web concern. The Android SSO flow opens a Custom Tab to the website's /auth/…/sso/start, so it rides the same server-side redirect transaction and the same tx cookies. The mobile side already ends in a code-exchange (/auth/mobile/sso/exchange) — which is the exact pattern v2's web callback is adopting. So: don't let "cookies go away" delete the tx cookies, or you break native SSO as well as web SSO.

Contract-sync checklist (this is a multi-repo change)

A v2 endpoint or auth change is not "done" until every consumer's contract is reconciled:

  • docs/website/BACKEND_DESIGN.md — route map + auth/security contract, incl. the /api/mobile facade as its own documented, version-agnostic surface with pinned response shapes.
  • server/swagger/swagger-output.json — regenerated (tag /api/mobile separately from v1/v2).
  • Facade contract tests — the frozen /api/mobile wire shapes; kept green across every v2 change.
  • docs/android/PLAN.md — the /api/mobile migration milestone + the app-version-floor mechanism.
  • The Android app's own endpoint constants + contract comments (data/api/*.kt, ShardStreamClient.kt, SsoAuthManager.kt) — re-pointed to /api/mobile in a separate Android-app PR.
  • Bot: note the SITE_PUBLIC_URL repoint in website/ deploy docs when /v2/public lands.

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.
  • Incomplete facade defeats the purpose — if any endpoint the app needs is left only under /api/v1, the app still calls v1 directly and Phase 0's decoupling silently leaks. Reconcile the facade against the app's full endpoint inventory before shipping PR 0a.
  • The facade is a security surface, not an alias — each /api/mobile route must carry the same auth middleware as the route it mirrors. A re-exposed admin route missing adminOnly, or the mobile SSE leaking sensitive kinds, is a privilege/data-exposure hole introduced by the facade.
  • Silent shape drift through the facade — once /api/mobile internals re-point to v2, a v2 response-shape change can break installed apps with no compile error. Contract tests on the facade are the guardrail; treat a failing one as a release blocker, not a test to update.
  • Double maintenance while v1 and v2 coexist — bug fixes may need both. Keep the window short; drive the web client to v2 completion. The facade means the window is no longer bounded by app behavior — only by the pre-facade fleet aging out (see § Cross-component blast radius).
  • Public SSE must not become auth-gated — the single most likely regression in Phase 1 is reflexively wrapping both shard streams in requireAuth. The public stream is anonymous by contract; doing so blacks out the public live boards for every logged-out browser and every phone.
  • v1 deletion is still fleet-gated (once) — even with the facade, the pre-facade installed app calls /api/v1 until the version floor ages it out. The facade contains this to a one-time, behavior-free cutover done before v2; it does not make v1 deletable on the web client's schedule.
  • link/ is not in scope but is adjacent — resist bumping PROTOCOL_VERSION or touching the sidecar client for v2 work; the only shared seam is preserving the admin-stream allowlist split.