diff --git a/android/PLAN.md b/android/PLAN.md index 483fd78..7ff7dce 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -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 (M6–M8) 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. --- diff --git a/website/API_V2_PLAN.md b/website/API_V2_PLAN.md index dc98b01..c24961e 100644 --- a/website/API_V2_PLAN.md +++ b/website/API_V2_PLAN.md @@ -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.