Merge pull request 'docs(website): cross-component blast-radius review + /api/mobile facade (Phase 0)' (#47) from docs/api-v2-blast-radius-mobile-facade into main

Reviewed-on: #47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-23 05:00:00 +00:00
2 changed files with 237 additions and 19 deletions

View File

@@ -648,6 +648,11 @@ Guidelines:
## 6. Screen ↔ endpoint map
> **Path note (M11):** endpoints below are written with their current `/api/v1`-relative paths. Under
> **M11** the app moves onto the version-agnostic **`/api/mobile`** facade (a pure rename — same
> shapes, same auth); these references update to `/api/mobile/**` when that migration executes. See
> [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0.
### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
- **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`),
@@ -878,6 +883,34 @@ push, and Play (M6M8) follow the designed app.
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup.
12. **M11 — Migrate to the version-agnostic `/api/mobile` facade** (post-v1; decided 2026-07-22).
Prerequisite for the website's API v2 work — see
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0. Today the app hardcodes ~70
`api/v1/…` endpoints, the SSE stream path, and the SSO URLs, has **no version negotiation and no
force-update**, and calls a mix of mobile-only (`/auth/mobile/*`) and *shared* web routes
(`/auth/me/*`, `/public/*`, `/player/*`, `/admin/*`) — so it is directly coupled to v1 and would
break the day v1 is retired. The website introduces a stable, version-agnostic `/api/mobile`
facade (a thin BFF that delegates to the current controllers behind pinned wire shapes); this
milestone moves the app onto it **once**, after which the app is insulated from all internal
v1→v2→vN churn.
- **App repoint (this milestone's core):** drop the `api/v1/` and `auth/mobile/` prefixes and
re-point everything to `/api/mobile` — the ~70 Retrofit endpoints in `data/api/*.kt`, the
`STREAM_PATH` in `core/net/ShardStreamClient.kt`, and the SSO start/exchange URLs in
`core/auth/sso/SsoAuthManager.kt`. Pure rename; no behavior, auth-model, or token-shape change
(the app is already bearer). The public shard stream stays **anonymous** (no `Authorization`
header) under its `/api/mobile` path.
- **App-version floor (ships in this milestone):** the app sends an app-version header on every
request, and the website gains a server-side min-supported-version gate that can return a
"please update" response. Its first job is to let the shard **sunset the pre-facade app** so the
website can finally delete `/api/v1`; thereafter it is the in-band mechanism for any breaking
`/api/mobile` change (URL-path versioning is deliberately absent on the facade).
- **Rollout & ordering:** the facade + app must be **released and adopted before the website
begins its v2 auth-merge / domain-split work** (website PR 0a = facade + telemetry; PR 0b =
this app repoint + the version floor). The old, un-updated app keeps working against frozen
`/api/v1` until the version floor ages it out — at which point v1 is deletable.
- **Not in scope:** any new screen, any auth-flow change, any UI work. This is a networking-layer
rename plus the version-floor plumbing. Once landed, the endpoint paths throughout §6 read
`/api/mobile/**`; update this doc's references when the migration executes.
---

View File

@@ -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.