diff --git a/android/PLAN.md b/android/PLAN.md index 7ff7dce..8891a0d 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -648,10 +648,9 @@ 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. +> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router +> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred — +> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade. ### 6.1 Public content - **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner). @@ -883,34 +882,16 @@ 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. + +### Deferred (not a milestone) + +- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now + **deferred with no app work scheduled**. The website's router refactor is being done in place with + every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…` + endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs + to diverge from web, the migration comes back — starting from a one-line alias mount on the server, + not a hand-written delegate layer. Reasoning and revival triggers: + [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade. --- diff --git a/website/API_V2_PLAN.md b/website/API_V2_PLAN.md index c24961e..c5b738d 100644 --- a/website/API_V2_PLAN.md +++ b/website/API_V2_PLAN.md @@ -1,159 +1,170 @@ -# Website API v2 — Plan +# Website API — router domain split + CSP hardening Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md` -v2 is three sequenced pieces of work, landed in order: +> **This file replaces the earlier "API v2" plan** (auth merge → CSP → domain split, with a parallel +> `/api/v2` mount and an `/api/mobile` facade). Three of those four pieces are **not being built**: +> the auth merge and the mobile facade are deferred with their reasoning recorded below, and the +> parallel-version scaffold in [API_V2_SKELETON.md](./API_V2_SKELETON.md) is superseded. The filename +> is kept so existing links resolve. What remains is genuinely useful work: +> +> 1. **CSP hardening** — small, independent, ships on its own cadence. +> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per +> business capability, **in place, with every URL unchanged**. This is the actual driver. -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 +## Why the auth merge is out -| Decision | Choice | Consequence | +The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for +every client, so web and mobile would share one session model. Reasons that no longer hold up: + +1. **The current model is the more secure one.** httpOnly + SameSite cookies are unreadable from JS + and carry CSRF protection by default. Every migration target is a sideways or backwards move: + - Refresh token in `localStorage` → any XSS becomes **persistent full account takeover**, not a + bounded access-token window. A short access TTL does not help; the attacker mints new pairs. + - Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is + *cookies with extra steps*. It concedes the premise. +2. **"One session model everywhere" is already true where it matters.** `auth/session.service.js` + unifies cookie and bearer into a single session, and `auth/token.js` already extracts from either + `Cookie` or `Authorization: Bearer`. That abstraction is written, working, and paid for. The merge + would move complexity *out* of `extractToken` and *into* the SPA. +3. **The cookie codepath survives the merge anyway.** The SSO / email-connect redirect flow must keep + its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the + IdP and returns with no JS context. So the merge never actually delivered "cookies are gone." +4. **It carried the plan's most bug-prone work as a dependency.** The admin SSE rewrite + (`EventSource` → hand-rolled `fetch` + `ReadableStream` + SSE frame parser + reconnect/backoff + + refresh-on-401) existed *only* to serve the bearer model. Without the merge, the admin stream stays + on `EventSource` with `withCredentials` and that code is never written. +5. **It is a contract change with no user-visible payoff**, competing for the same review attention as + the domain split, which is the thing that actually hurts today. + +### Dropped with it + +- v2 bearer auth routes (`/api/v2/auth/{login,refresh,logout,login/totp}`). +- Deletion of the `setAuthCookie` / `clearAuthCookie` path. +- `rg_trust` cookie → `X-Trust-Token` header migration for web (the header stays available for native + clients via `extractTrustToken`, unchanged). +- `api/client.js` bearer + silent-refresh rewrite. +- `lib/useShardFeed.js` admin-stream fetch rewrite. **The admin SSE stream stays as-is.** + +### Kept from it + +- **CSP hardening** — now its own phase (below). It was justified as a compensating control for a + JS-held token; it is worth doing regardless, just no longer urgent. +- **The public/admin SSE allowlist split** — unchanged security boundary, unrelated to session model. +- **The tx-cookie carve-out reasoning** — recorded here so a future merge attempt doesn't rediscover it. + +--- + +## Deferred: the auth merge + +Not cancelled — parked behind trigger conditions. Revisit if **any** of these become true: + +| Trigger | Why it changes the answer | +|---|---| +| The API becomes genuinely cross-origin (separate API host) | `SameSite` cookies stop being the easy path; bearer becomes the natural model. | +| Third-party or OAuth clients are introduced | Cookies don't serve clients you don't control. | +| Mobile and web session behavior diverge enough to cause real bugs | The unification argument gets teeth it currently lacks. | + +If it is ever revived, two specs the original plan lacked must be written **first**: + +- **Refresh-token reuse detection.** Rotation is only useful with it: replay of an already-consumed + refresh must revoke the entire token family, not just fail the one request. +- **Rollback procedure.** Once web clients have discarded their cookies, a bad deploy locks everyone + out. Needs a documented path back. + +--- + +## Why there is no `/api/v2` + +The domain split reorganizes router *files*. It does not need to move a single URL — because the URL +surface is **already grouped by capability**. Inventory taken from the live Express stack — 196 +`/api/v1` routes, plus three outside it (`GET /api/health`, `GET /api/docs.json`, +`GET /.well-known/assetlinks.json`) and 2 on the internal port. Full machine-readable list: +[`api-route-inventory.json`](./api-route-inventory.json). + +| Group | Routes | Second segment → capability | |---|---|---| -| 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. | +| `/admin` | 110 | `shard` 16 · `moderation` 15 · `users` 15 · `wiki` 14 · `posts` 9 · `pages` 7 · `account` 6 · `email` 6 · `uo-link` 5 · `auth` 4 · `invites` 3 · `bot-activity` 2 · `discord-bot` 2 · `settings` 2 · `activity` 1 · `dashboard` 1 · `site-mode` 1 · `uploads` 1 | +| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 | +| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 | +| `/player` | 20 | `account` 8 · `shard` 8 · `appeals` 4 | -## What already exists (so we don't rebuild it) +Every capability already owns a URL prefix, so each new router file mounts at the prefix it already +owns and the emitted paths are **byte-identical**. No URL change means no contract change, and no +contract change means no reason to mount a parallel version. -- `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.) +Consequences of doing it in place: + +- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no + v1-retirement sequence. +- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL` + never changes. The Android app is untouched. +- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not + scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API. + +**Tripwire:** if any endpoint turns out to *need* a new URL, that is a contract change, not a +refactor. List it explicitly, and reopen the versioning question before writing the code — do not +smuggle a URL change into a "mechanical" PR. --- -## Phase 0 — The mobile facade (`/api/mobile`, lands before v2) +## Deferred: the `/api/mobile` facade and the app-version floor -**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. +The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the +Android app onto it, plus an app-version header and a server-side min-version floor. -**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. +**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`, +`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`, +`core/auth/sso/SsoAuthManager.kt`), has no version negotiation and no force-update, and installs in +the wild cannot be forced forward. Under a parallel-`/api/v2` plan that made the app the load-bearing +coupling: v1 could not be retired until the fleet aged out. -### Server +**Why it is deferred:** with the split done in place, no URL moves and nothing is being deleted — so +there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained +delegate routes plus a contract-test suite to solve a problem that does not currently exist. The +version floor was scoped to sunsetting the pre-facade fleet, so it goes with it. -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. +**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web — +different response shapes, a mobile-only aggregation endpoint, a real breaking change): -### 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. +- **Start with the alias mount, not a delegate layer:** `apiRouter.use('/mobile', v1Router)` gives the + app a stable, version-agnostic namespace with identical wiring, identical middleware and zero + per-route maintenance. Build hand-written delegates only for the routes that actually diverge. +- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the + *same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators, + the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege + escalation. +- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a + committed contract; an internal refactor that changes a shape must fail a test before it ships to + installed apps. +- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no + `Authorization` header. --- -## Phase 1 — The auth merge (cookie removal) +## Phase 1 — CSP hardening (independent) -### Server +Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in +JS, this is **defense in depth on its own merits** — cheap, worth doing, blocking nothing. It has no +dependency on the domain split and can ship at any time. -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. +**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called +for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one +release, then enforce** — now trivially satisfiable since nothing waits on it. -### Client +The app already ships a tuned policy (`server/src/app.js`). Two directives are load-bearing and are +**already correct** — the job is to keep them that way: -6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer `; - 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. +- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense. +- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes + cross-origin (which would also reopen the auth-merge question — see the trigger table). `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. +pervasive `style={{…}}` attributes can't be nonce'd. Not a meaningful hole. The `/api/docs` route keeps +its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is +scoped to the one route and stays scoped. Target enforced policy: @@ -170,209 +181,189 @@ form-action 'self'; frame-ancestors 'none'; ``` -Changes vs. the current policy: +Delta vs. the policy in `server/src/app.js` today — the whole change is two directives: -- **Add `form-action 'self'`** — blocks an injected `
` 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 `` in sanitized wiki/news bodies rely on `https:`). +- **Add `form-action 'self'`** (currently absent) — blocks an injected `` + from POSTing credentials off-origin, an exfil path `connect-src` doesn't cover. +- **Tighten `frame-ancestors`** `'self'` → `'none'` — nothing legitimately frames the site. +- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and + `img-src … https:` (external `BRAND_*` logo/hero and `` in sanitized wiki/news bodies rely on + `https:`). -Tracked follow-ups (own PRs, not blocking the merge): +**Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for +violations, then flip to enforce. -- **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. +Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `