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
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:
- The mobile facade (lands first, before any v2 work) — a version-agnostic
/api/mobilenamespace 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. - 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 |
|---|---|---|
| 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.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
internal
/mobileauth namespace collapses into unified/auth/*, and the cookie code path is removed. (The app-facing/api/mobilefacade 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
- Mount
/api/mobileinapi.router.jsnext to/v1(today it mounts only/v1). - 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 anonymouspublic/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). - 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 missingadminOnlyis a privilege-escalation hole — treat the facade as a security surface, not a convenience alias. - Keep the mobile SSE stream anonymous.
/api/mobile/…/shard/streamcarries no auth (the app sends noAuthorizationheader); only the public/safe kinds, same allowlist as today. - Pin the wire shapes with contract tests.
/api/mobileis 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)
- Re-point everything to
/api/mobile: the ~70 Retrofit endpoints (data/api/*.kt), the SSE path inShardStreamClient.kt, and the SSO start/exchange URLs inSsoAuthManager.kt. Drop theapi/v1/andauth/mobile/prefixes; the app now knows only/api/mobile. - 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/v1can eventually be deleted; thereafter it is insurance for any genuinely breaking/api/mobilechange (negotiated in-band, since URL-path versioning is deliberately gone here). - Ship and let the fleet adopt before starting v2. The old app keeps working on frozen
/api/v1until the version floor ages it out.
Docs / spec
- Document
/api/mobileas its own tagged surface in Swagger; record the facade + the version-floor mechanism indocs/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.
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 auth is per-channel — the public stream stays anonymous. The admin shard stream
(today gated by the session cookie via
isLoggedIn) moves torequireAuthon 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 AndroidShardStreamClient, neither of which sends anAuthorizationheader. AddingrequireAuthto it would black out the public live boards on web and mobile. 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: only the admin stream needs the rewrite — replaceEventSource(adminShardStreamUrl, { withCredentials: true })with afetch()+ReadableStreamreader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff + access-token refresh-on-401. The public stream stays onEventSource(no credentials, so nothing to change) and keeps its free auto-reconnect. Do not convert both blindly.
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
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.
- PR 0a — mobile facade (server): mount
/api/mobileinapi.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. - PR 0b — mobile migration (separate
Android-apprepo): re-point the ~70 Retrofit endpoints, theShardStreamClientSSE 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. Seedocs/android/PLAN.md. - PR 1 — v2 scaffold:
router/v2/skeleton,v2.router.js, mount/v2next to/v1. Empty but wired; no behavior change. Detailed in API_V2_SKELETON.md. - 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. - PR 3 — auth merge (web client):
client.jsbearer + silent-refresh;useShardFeed.jsfetch stream for the admin stream only. - 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/mobiledelegating correctly as controllers move; contract tests catch any shape drift. - PR final — retire v1: only once (a) the web client is fully on v2, and (b) telemetry shows
no
/api/v1traffic from the pre-facade app, i.e. the old fleet has aged past the version floor. Then deleterouter/v1and the dead cookie code intoken.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:
- 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/v1directly and is not actually insulated. This is the single most important Phase 0 check. - 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/v1stays 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. - 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/v1can finally be deleted; afterward it is the in-band mechanism for any breaking/api/mobilechange, since URL-path versioning is deliberately absent there. Without it there is still no safe way to force the last stragglers off v1. - 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.
/api/mobileis 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."- The public/mobile SSE stream must stay anonymous (Phase 0 step 4 / Phase 1 step 5) — the app's
ShardStreamClientsends no auth header. - Record the mobile side in
docs/android/PLAN.md. This plan owns the/api/mobileserver contract + the facade; that plan owns the app migration + version-floor rollout.
Shard (site ↔ link) — mostly out of the blast radius, with one seam to guard
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, andPROTOCOL_VERSION/X-UOLink-Versionare their own versioning axis. None of that changes for v2 —link/needs no edit andPROTOCOL_VERSIONdoes 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 intolink/.
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/mobilefacade as its own documented, version-agnostic surface with pinned response shapes.server/swagger/swagger-output.json— regenerated (tag/api/mobileseparately from v1/v2).- Facade contract tests — the frozen
/api/mobilewire shapes; kept green across every v2 change. docs/android/PLAN.md— the/api/mobilemigration 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/mobilein a separateAndroid-appPR. - Bot: note the
SITE_PUBLIC_URLrepoint inwebsite/deploy docs when/v2/publiclands.
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. - 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/mobileroute must carry the same auth middleware as the route it mirrors. A re-exposed admin route missingadminOnly, 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/mobileinternals 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/v1until 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 bumpingPROTOCOL_VERSIONor touching the sidecar client for v2 work; the only shared seam is preserving the admin-stream allowlist split.