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
This commit is contained in:
@@ -2,8 +2,12 @@
|
||||
|
||||
Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
|
||||
|
||||
v2 is two sequenced pieces of work, landed in order:
|
||||
v2 is three sequenced pieces of work, landed in order:
|
||||
|
||||
0. **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.
|
||||
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
|
||||
@@ -14,9 +18,10 @@ v2 is two sequenced pieces of work, landed in order:
|
||||
|
||||
| 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. |
|
||||
| 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 | **fetch-based streaming with `Authorization: Bearer`** | Token stays out of URLs/logs. Client re-implements reconnect/backoff that `EventSource` gave for free. |
|
||||
| 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)
|
||||
|
||||
@@ -26,7 +31,62 @@ v2 is two sequenced pieces of work, landed in order:
|
||||
`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.
|
||||
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)
|
||||
|
||||
6. **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`.
|
||||
7. **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).
|
||||
8. **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
|
||||
|
||||
9. 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,9 +112,13 @@ v2 is two sequenced pieces of work, landed in order:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -62,9 +126,11 @@ v2 is two sequenced pieces of work, landed in order:
|
||||
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.
|
||||
8. **`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)
|
||||
|
||||
@@ -167,19 +233,120 @@ Steps:
|
||||
|
||||
## Sequencing & PR breakdown
|
||||
|
||||
1. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
|
||||
**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](./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`.
|
||||
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.
|
||||
|
||||
### 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, and `PROTOCOL_VERSION` / `X-UOLink-Version`
|
||||
are their own versioning axis. **None of that changes for v2** — `link/` 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:
|
||||
@@ -189,5 +356,23 @@ edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `
|
||||
- **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.
|
||||
- **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 client migration to completion before adding new v1-only features.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user