Files
docs/website/API_V2_PLAN.md
wtclaude 3eafaef97e 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
2026-07-22 23:54:15 -05:00

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.