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
379 lines
25 KiB
Markdown
379 lines
25 KiB
Markdown
# 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:
|
|
|
|
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
|
|
broken into one router file per business capability, so a developer can predict where an
|
|
endpoint lives from its URL.
|
|
|
|
## Locked decisions
|
|
|
|
| Decision | Choice | Consequence |
|
|
|---|---|---|
|
|
| Mobile decoupling | **A version-agnostic `/api/mobile` facade, landed before v2** | The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely. |
|
|
| Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the *web* client route-by-route; delete v1 once no caller remains. No big-bang break. |
|
|
| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. |
|
|
| Live-feed (SSE) auth | **admin stream → fetch + `Authorization: Bearer`; public stream stays anonymous** | Admin token stays out of URLs/logs. Public/mobile stream keeps `EventSource`, no auth header. |
|
|
|
|
## What already exists (so we don't rebuild it)
|
|
|
|
- `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and
|
|
`auth/session.service.js` already unifies both into one session.
|
|
- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` /
|
|
`refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side.
|
|
Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`.
|
|
- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the
|
|
internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is
|
|
removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the
|
|
unified flow; the app sees no change. See Phase 0.)
|
|
|
|
---
|
|
|
|
## Phase 0 — The mobile facade (`/api/mobile`, lands before v2)
|
|
|
|
**Goal:** make the Android app's contract *version-agnostic* so the v2 work below can proceed without
|
|
ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE
|
|
path and SSO URLs) and has **no version negotiation and no force-update** — which is exactly why v1
|
|
can't otherwise be retired on the web client's schedule. Phase 0 pays that migration **once**, up
|
|
front, against a pure rename with no behavior change, and never again.
|
|
|
|
**What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to
|
|
the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional
|
|
response-shaping*, never business logic — a Backend-for-Frontend, not a fork.
|
|
|
|
### Server
|
|
|
|
1. **Mount `/api/mobile` in `api.router.js`** next to `/v1` (today it mounts only `/v1`).
|
|
2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls
|
|
mostly *shared* routes (`auth/me/*`, `public/*`, `player/*`, `admin/*`) plus the mobile-only auth
|
|
routes and the anonymous `public/shard/stream`. All of them move under `/api/mobile/**`. If any
|
|
endpoint the app needs is left only under `/api/v1`, the app is not decoupled and the whole point
|
|
is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius).
|
|
3. **Delegate; never bypass auth.** Each facade route requires the *same* middleware chain as its
|
|
underlying route (`requireAuth`, `staffOnly`/`adminOnly`, validators, the public/admin SSE
|
|
allowlist split). A re-exposed admin route missing `adminOnly` is a privilege-escalation hole —
|
|
treat the facade as a security surface, not a convenience alias.
|
|
4. **Keep the mobile SSE stream anonymous.** `/api/mobile/…/shard/stream` carries no auth (the app
|
|
sends no `Authorization` header); only the public/safe kinds, same allowlist as today.
|
|
5. **Pin the wire shapes with contract tests.** `/api/mobile` is now a **committed stable contract**:
|
|
a v2 internal refactor that changes a response shape must fail a test here *before* it can ship to
|
|
installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI.
|
|
|
|
### Android app (`Android-app` repo — separate PR, separate release)
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Phase 1 — The auth merge (cookie removal)
|
|
|
|
### Server
|
|
|
|
1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`:
|
|
- `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`).
|
|
- `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair.
|
|
- `POST /logout` → revoke the current refresh/session server-side.
|
|
- `POST /login/totp` → second-factor step, same token shape.
|
|
The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these.
|
|
2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` /
|
|
`clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2
|
|
routes (v1 keeps it until v1 is removed).
|
|
3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email-
|
|
connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its
|
|
short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP
|
|
and returns with no JS context to carry a bearer across the hop. Only the **final session**
|
|
handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the
|
|
SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`.
|
|
4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients
|
|
(`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is
|
|
dropped for v2.
|
|
5. **SSE auth is per-channel — the public stream stays anonymous.** The **admin** shard stream
|
|
(today gated by the session cookie via `isLoggedIn`) moves to `requireAuth` on the Bearer header,
|
|
since its browser client becomes fetch-based (below). The **public** shard stream
|
|
(`/public/shard/stream`) has **no auth middleware today and must keep none** — it is consumed by
|
|
logged-out browser visitors *and* by the Android `ShardStreamClient`, neither of which sends an
|
|
`Authorization` header. Adding `requireAuth` to it would black out the public live boards on web
|
|
and mobile. Keep the public/admin allowlist split — that security boundary is unchanged.
|
|
|
|
### Client
|
|
|
|
6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer <access>`;
|
|
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`:** 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)
|
|
|
|
9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception).
|
|
10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme,
|
|
remove `Set-Cookie` from documented responses.
|
|
|
|
---
|
|
|
|
## Phase 1b — CSP hardening (ships with the auth merge)
|
|
|
|
Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't
|
|
run, and if it somehow runs it can't phone home.** The app already ships a tuned policy
|
|
(`server/src/app.js`); v2 tightens it rather than rewriting it.
|
|
|
|
Two directives are load-bearing for the token-theft threat and must stay tight:
|
|
|
|
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it.
|
|
- `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host)
|
|
unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here.
|
|
|
|
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
|
|
pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole.
|
|
|
|
Target enforced policy:
|
|
|
|
```
|
|
default-src 'self';
|
|
script-src 'self';
|
|
connect-src 'self';
|
|
img-src 'self' data: https:;
|
|
style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */
|
|
font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */
|
|
object-src 'none';
|
|
base-uri 'self';
|
|
form-action 'self';
|
|
frame-ancestors 'none';
|
|
```
|
|
|
|
Changes vs. the current policy:
|
|
|
|
- **Add `form-action 'self'`** — blocks an injected `<form action="https://evil">` from POSTing the
|
|
token/credentials off-origin (an exfil path `connect-src` doesn't cover).
|
|
- **Tighten `frame-ancestors` `'self'` → `'none'`** — nothing legitimately frames the site.
|
|
- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*`
|
|
logo/hero and `<img>` in sanitized wiki/news bodies rely on `https:`).
|
|
|
|
Tracked follow-ups (own PRs, not blocking the merge):
|
|
|
|
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and
|
|
`fonts.gstatic.com` from `font-src`, removing two third-party origins from the trust surface.
|
|
- **Trusted Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in
|
|
**report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would
|
|
steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
|
|
- **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with
|
|
`report-to` for one release, watch for violations, then flip to enforce.
|
|
|
|
Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill
|
|
`<script>` into `dist/index.html`, which that directive blocks (harmless but throws a violation).
|
|
Confirm it's disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The
|
|
`renderIndexHtml` branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce
|
|
needed.)
|
|
|
|
---
|
|
|
|
## Phase 2 — The domain split
|
|
|
|
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
|
|
live together regardless of HTTP method; no generic `admin.router.js` / `api.router.js` catch-alls.
|
|
Controllers are **already** domain-split — Phase 2 is mostly re-wiring the routes, not the logic.
|
|
|
|
Target tree (`router/v2/`):
|
|
|
|
```
|
|
router/v2/
|
|
admin/
|
|
dashboard.router.js users.router.js moderation.router.js
|
|
content.router.js wiki.router.js shard.router.js
|
|
settings.router.js invites.router.js bot-activity.router.js
|
|
auth/
|
|
login.router.js sso.router.js totp.router.js session.router.js
|
|
public/
|
|
news.router.js wiki.router.js page.router.js shard.router.js
|
|
player/
|
|
profile.router.js appeals.router.js shard.router.js
|
|
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
|
|
```
|
|
|
|
Steps:
|
|
|
|
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
|
|
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
|
|
12. Split `public.routes.js` and `player.routes.js` the same way.
|
|
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
|
|
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
|
|
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
|
|
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
|
|
|
|
---
|
|
|
|
## Sequencing & PR breakdown
|
|
|
|
**Phase 0 lands entirely before the v2 scaffold** — the app must be off `/api/v1`-direct and onto
|
|
`/api/mobile` (and the fleet adopting) before v2 behavior work begins.
|
|
|
|
1. **PR 0a — mobile facade (server):** mount `/api/mobile` in `api.router.js`; re-home the app's full
|
|
endpoint surface as thin delegates to existing controllers (same middleware); add contract tests
|
|
pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
|
|
2. **PR 0b — mobile migration (separate `Android-app` repo):** re-point the ~70 Retrofit endpoints,
|
|
the `ShardStreamClient` SSE path, and the SSO start/exchange URLs to `/api/mobile`; add the
|
|
app-version header + server-side min-version floor. Released and adopted on the app-store cadence
|
|
before v2 starts. See `docs/android/PLAN.md`.
|
|
3. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
|
|
but wired; no behavior change. Detailed in [API_V2_SKELETON.md](./API_V2_SKELETON.md).
|
|
4. **PR 2 — auth merge (server):** v2 bearer auth routes + SSO callback code-exchange + admin-SSE-on-
|
|
Bearer + docs/swagger. **Re-point `/api/mobile`'s internals to the unified flow behind unchanged
|
|
wire shapes** (contract tests must stay green) — the app sees nothing.
|
|
5. **PR 3 — auth merge (web client):** `client.js` bearer + silent-refresh; `useShardFeed.js` fetch
|
|
stream for the **admin** stream only.
|
|
6. **PR 4…N — domain split:** one PR per admin capability (dashboard, users, moderation, content,
|
|
wiki, shard, settings, …) to keep diffs reviewable; then public + player. Keep `/api/mobile`
|
|
delegating correctly as controllers move; contract tests catch any shape drift.
|
|
7. **PR final — retire v1:** only once (a) the web client is fully on v2, **and** (b) telemetry shows
|
|
no `/api/v1` traffic from the pre-facade app, i.e. the old fleet has aged past the version floor.
|
|
Then delete `router/v1` and the dead cookie code in `token.js`. See § Cross-component blast radius.
|
|
|
|
Each PR: server tests green (`cd website/server && npm test`), Swagger regenerated, matching `docs/`
|
|
edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `main`.
|
|
|
|
## Cross-component blast radius
|
|
|
|
The plan above is written as if the website is the whole world. It isn't. **Three independent
|
|
clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release
|
|
cadences.** Any change to a URL shape, an auth mechanism, or a namespace is a *contract* change, not
|
|
a local refactor — the same discipline the docs demand for the shard↔sidecar wire protocol applies
|
|
here. This section is the coordination map the per-repo work must respect.
|
|
|
|
### Who calls the site API
|
|
|
|
| Consumer | Repo (release cadence) | How it's pinned to v1 | Migration cost |
|
|
|---|---|---|---|
|
|
| Browser SPA | `website/client` (same repo, lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | In-plan (Phase 1 client + Phase 2) |
|
|
| **Android app** | `Android-app` (separate repo, app-store cadence, un-updatable installs in the wild) | **~70 Retrofit endpoints hardcode `api/v1/…`** across `data/api/*.kt`; SSE path hardcoded in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **Migrated once to the version-agnostic `/api/mobile` facade in Phase 0; thereafter decoupled from v2.** |
|
|
| Discord bot | `website/bot` (separate deploy, but env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` (`announce`/`wiki` commands) | Trivial — repoint the env var once `/v2/public` exists; unaffected by the auth merge (anonymous public reads) |
|
|
|
|
Only the browser migrates in true lockstep. The bot is a one-line env change. **The Android app was
|
|
the load-bearing coupling — the `/api/mobile` facade (Phase 0) is what removes it from the critical
|
|
path so v2 can proceed freely.**
|
|
|
|
### Mobile (site ↔ Android) — decoupled by the Phase 0 facade
|
|
|
|
The facade turns the mobile problem from "the app must chase every version rename forever" into "the
|
|
app pins one stable namespace, migrated once." What remains to keep in view:
|
|
|
|
1. **The facade must be *complete* or the decoupling leaks.** The app uses mostly *shared* routes
|
|
(`auth/me/*`, `public/*`, `player/*`, `admin/*`), not just the mobile-auth ones. Every endpoint in
|
|
the app's inventory (below) must exist under `/api/mobile`, or the app still calls `/api/v1`
|
|
directly and is not actually insulated. This is the single most important Phase 0 check.
|
|
2. **The one-time cutover is still fleet-gated — the facade doesn't erase that, it *contains* it.**
|
|
The *pre-facade* installed app still calls `/api/v1/*`, and with no force-update it does so until
|
|
its users update. So `/api/v1` stays alive until that old fleet ages out. The difference: this is
|
|
now a **pure-rename** migration decoupled from v2 behavior, paid once, and closed out by the
|
|
version floor (next point) rather than blocking the auth merge.
|
|
3. **The version floor is still needed — its role just changed.** Add an app-version header + a
|
|
server-side min-version gate (Phase 0 step 7). Its *first* job is to sunset the pre-facade app so
|
|
`/api/v1` can finally be deleted; *afterward* it is the in-band mechanism for any breaking
|
|
`/api/mobile` change, since URL-path versioning is deliberately absent there. Without it there is
|
|
still no safe way to force the last stragglers off v1.
|
|
4. **v1-usage telemetry** — per-route counts, tagged to distinguish the migrated browser from
|
|
lingering pre-facade app installs, so "nothing calls v1" is measured, not assumed.
|
|
5. **`/api/mobile` is now a frozen wire contract** — contract tests (Phase 0 step 5) must guard its
|
|
response shapes so a v2 internal refactor can't silently break installed apps. This replaces
|
|
"don't break the app during the auth merge" with "the auth merge can't reach the app at all."
|
|
6. **The public/mobile SSE stream must stay anonymous** (Phase 0 step 4 / Phase 1 step 5) — the app's
|
|
`ShardStreamClient` sends no auth header.
|
|
7. **Record the mobile side in `docs/android/PLAN.md`.** This plan owns the `/api/mobile` server
|
|
contract + the facade; that plan owns the app migration + version-floor rollout.
|
|
|
|
### 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:
|
|
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** — `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 *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.
|