Fixes#295 (PR2 — the SPA cutover) and #301 (side-effecting-GET CSRF hole).
PR1 shipped the entire server auth surface (session-OR-key filter, AuthController, local-admin store, OIDC, cookie). This PR moves the browser onto the session and closes the #301 CSRF gap.
What changed
SPA is cookie-only; machine key is external/MCP-only
web/src/api/client.ts no longer sends X-Api-Key; it relies on the same-origin session cookie and sets X-Csrf on every mutating verb centrally.
New boot gateweb/src/AuthGate.tsx (wraps <App/> in main.tsx, not a route → no parity rows): GET /api/auth/config → Setup (first-run local-admin claim) / GET /api/auth/session → Login (local form + OIDC "Sign in with SSO" when enabled) / the app. Publishes AuthContext.
ApiKeyScreen repurposed to machine-key management — reads the key from the new GET /api/auth/machine-key (session-gated), masked with Reveal + Copy; plus a local-admin password-change card. Logout via a UserMenu in the TopBar.
401 signal now drives re-login (passive banner, consults the nav guard); login/change-password suppress it for inline handling.
#301 — POST-ify the side-effecting GETs (so the filter's session-mutation CSRF gate covers them, no new machinery)
GET /api/troubleshoot/playback.m3u8 → POST /api/troubleshoot/playback/start returning 200 { url } (the open /iptv manifest the player loads — no header injection into hls.js/native-HLS needed).
archive/sample GETs → POST (SPA downloads via a fetch-blob helper, never window.open).
Removing the HEAD variants also fixed a latent DeleteOnClose-on-HEAD artifact-destruction bug.
Standing rule added: never add a side-effecting GET/HEAD under /api.
Two integration bugs caught + fixed during assembly: the MachineKey wire field ({ apiKey } not { key }, mocked unit tests missed it) and stale OpenApiErrorResponseContractTests cases pointing at the removed route.
Docs (same PR)
spa-conventions.md §5e rewritten (session-auth seams), api-conventions.md §9 (#301 resolved + standing rule + machine-key endpoint), decisions.md new entry, e2e-local.md (fail-closed seeding + browser boot-gate flow). No blazor-route-parity.md/domain-model.md rows (login/setup mint no routes).
Pending before merge
Live E2E (setup-claim → login → machine-key → troubleshoot playback POST → archive download → logout)
Fixes #295 (PR2 — the SPA cutover) and #301 (side-effecting-GET CSRF hole).
PR1 shipped the entire server auth surface (session-OR-key filter, `AuthController`, local-admin store, OIDC, cookie). **This PR moves the browser onto the session** and closes the #301 CSRF gap.
## What changed
**SPA is cookie-only; machine key is external/MCP-only**
- `web/src/api/client.ts` no longer sends `X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf` on every mutating verb centrally.
- New **boot gate** `web/src/AuthGate.tsx` (wraps `<App/>` in `main.tsx`, **not** a route → no parity rows): `GET /api/auth/config` → Setup (first-run local-admin claim) / `GET /api/auth/session` → Login (local form + OIDC "Sign in with SSO" when enabled) / the app. Publishes `AuthContext`.
- `ApiKeyScreen` repurposed to **machine-key management** — reads the key from the new `GET /api/auth/machine-key` (session-gated), masked with Reveal + Copy; plus a local-admin password-change card. Logout via a `UserMenu` in the TopBar.
- 401 signal now drives re-login (passive banner, consults the nav guard); login/change-password suppress it for inline handling.
**#301 — POST-ify the side-effecting GETs** (so the filter's session-mutation CSRF gate covers them, no new machinery)
- `GET /api/troubleshoot/playback.m3u8` → **`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player loads — no header injection into hls.js/native-HLS needed).
- archive/sample GETs → **POST** (SPA downloads via a fetch-blob helper, never `window.open`).
- Removing the HEAD variants also fixed a latent `DeleteOnClose`-on-HEAD artifact-destruction bug.
- Standing rule added: never add a side-effecting GET/HEAD under `/api`.
## Verification
- C# `ErsatzTV.Tests`: **1529/1529** green. Web: **731/731** green, lint + build + `check:api` clean. OpenAPI regenerated (`v1.json`/`v1.d.ts`/`endpoint-index.md`).
- Two integration bugs caught + fixed during assembly: the `MachineKey` wire field (`{ apiKey }` not `{ key }`, mocked unit tests missed it) and stale `OpenApiErrorResponseContractTests` cases pointing at the removed route.
## Docs (same PR)
`spa-conventions.md §5e` rewritten (session-auth seams), `api-conventions.md §9` (#301 resolved + standing rule + machine-key endpoint), `decisions.md` new entry, `e2e-local.md` (fail-closed seeding + browser boot-gate flow). No `blazor-route-parity.md`/`domain-model.md` rows (login/setup mint no routes).
## Pending before merge
- [ ] Live E2E (setup-claim → login → machine-key → troubleshoot playback POST → archive download → logout)
- [ ] Adversarial review (fork + Codex) — security-critical; `Review-verdict: … @ <head>` to follow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Codex (initial diff) → BLOCKED, 4 MAJOR + 1 MINOR — critically caught a side-effecting GET the fork missed (GET /api/graphics-elements?refresh=true writes to the DB), plus lock-race→422-not-409, a fast-completion screen race, machine-key response missing no-store, and an AuthSession type-vs-wire mismatch.
Fable reconciliation → ratified Codex's BLOCKED; prescribed the fix plan (rejected 1 fork nit as non-defect, deferred 1 pre-existing to #320).
Fixes landed (aa86fb6f backend + 071f339a web): graphics-refresh POST-ified (GET now pure-read), typed LockedError→409, onPlay phase-promotion on POST-200, Cache-Control: no-store on the machine key, AuthSession optional fields. Plus a CI-gate fix (2e056a12): update-openapi.sh now builds before the doc-gen target (the new #303 api-docs gate hit a latent deps.json failure — this PR was the first to change the /api surface and exercise it).
Fork re-review (fix commits) → MERGEABLE, all 5 findings verified CLOSED in code; one latent cosmetic nit accepted (hardcoded 409 title on the generic LockedError, single call site).
Live E2E (fresh instance, headless): 5/5 flows PASS — setup-claim → login → machine-key screen (revealed key == $CONFIG/api.key, no-store present) → CSRF gating (403 without X-CSRF, 204 with) → old GET playback.m3u8 → 404 → logout/lockout safety.
## Adversarial review — complete
Review loop (security gate, so both fork + Codex per rotation, reconciled by Fable, then a fix-commit re-review per the #242 discipline):
- **Fork (initial diff)** → MERGEABLE-WITH-NITS (verified CSRF completeness on Troubleshoot/ScriptedSchedule, machine-key gating, boot-gate lockout).
- **Codex (initial diff)** → BLOCKED, 4 MAJOR + 1 MINOR — critically caught a side-effecting GET the fork missed (`GET /api/graphics-elements?refresh=true` writes to the DB), plus lock-race→422-not-409, a fast-completion screen race, machine-key response missing `no-store`, and an `AuthSession` type-vs-wire mismatch.
- **Fable reconciliation** → ratified Codex's BLOCKED; prescribed the fix plan (rejected 1 fork nit as non-defect, deferred 1 pre-existing to #320).
- **Fixes landed** (`aa86fb6f` backend + `071f339a` web): graphics-refresh POST-ified (GET now pure-read), typed `LockedError`→409, `onPlay` phase-promotion on POST-200, `Cache-Control: no-store` on the machine key, `AuthSession` optional fields. Plus a CI-gate fix (`2e056a12`): `update-openapi.sh` now builds before the doc-gen target (the new #303 api-docs gate hit a latent `deps.json` failure — this PR was the first to change the `/api` surface and exercise it).
- **Fork re-review (fix commits)** → **MERGEABLE**, all 5 findings verified CLOSED in code; one latent cosmetic nit accepted (hardcoded 409 title on the generic `LockedError`, single call site).
**Live E2E** (fresh instance, headless): 5/5 flows PASS — setup-claim → login → machine-key screen (revealed key == `$CONFIG/api.key`, `no-store` present) → CSRF gating (403 without `X-CSRF`, 204 with) → old `GET playback.m3u8` → 404 → logout/lockout safety.
**CI**: all 7 checks green on `2e056a12`.
Review-verdict: MERGEABLE @ 2e056a12c1d8c7f45ecae44cd6de9655a4eef29b
Re-anchoring the verdict after rebasing onto the latest main (past #315 + the parallel-session CI chores). The rebase is content-identical to the reviewed 2e056a12 — main's additions were disjoint (docs/scripts/migration-smoke); no code or reviewed surface changed. Full CI is green on the new head.
Re-anchoring the verdict after rebasing onto the latest `main` (past #315 + the parallel-session CI chores). The rebase is **content-identical** to the reviewed `2e056a12` — main's additions were disjoint (docs/scripts/migration-smoke); no code or reviewed surface changed. Full CI is green on the new head.
Review-verdict: MERGEABLE @ be9cf5f381755b93e3efd04b51b70e1e57314858
timothy
merged commit fa655053ba into main2026-07-12 22:04:54 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #295 (PR2 — the SPA cutover) and #301 (side-effecting-GET CSRF hole).
PR1 shipped the entire server auth surface (session-OR-key filter,
AuthController, local-admin store, OIDC, cookie). This PR moves the browser onto the session and closes the #301 CSRF gap.What changed
SPA is cookie-only; machine key is external/MCP-only
web/src/api/client.tsno longer sendsX-Api-Key; it relies on the same-origin session cookie and setsX-Csrfon every mutating verb centrally.web/src/AuthGate.tsx(wraps<App/>inmain.tsx, not a route → no parity rows):GET /api/auth/config→ Setup (first-run local-admin claim) /GET /api/auth/session→ Login (local form + OIDC "Sign in with SSO" when enabled) / the app. PublishesAuthContext.ApiKeyScreenrepurposed to machine-key management — reads the key from the newGET /api/auth/machine-key(session-gated), masked with Reveal + Copy; plus a local-admin password-change card. Logout via aUserMenuin the TopBar.#301 — POST-ify the side-effecting GETs (so the filter's session-mutation CSRF gate covers them, no new machinery)
GET /api/troubleshoot/playback.m3u8→POST /api/troubleshoot/playback/startreturning200 { url }(the open/iptvmanifest the player loads — no header injection into hls.js/native-HLS needed).window.open).DeleteOnClose-on-HEAD artifact-destruction bug./api.Verification
ErsatzTV.Tests: 1529/1529 green. Web: 731/731 green, lint + build +check:apiclean. OpenAPI regenerated (v1.json/v1.d.ts/endpoint-index.md).MachineKeywire field ({ apiKey }not{ key }, mocked unit tests missed it) and staleOpenApiErrorResponseContractTestscases pointing at the removed route.Docs (same PR)
spa-conventions.md §5erewritten (session-auth seams),api-conventions.md §9(#301 resolved + standing rule + machine-key endpoint),decisions.mdnew entry,e2e-local.md(fail-closed seeding + browser boot-gate flow). Noblazor-route-parity.md/domain-model.mdrows (login/setup mint no routes).Pending before merge
Review-verdict: … @ <head>to follow.🤖 Generated with Claude Code
e433332428to1c3b4ad2641c3b4ad264to071f339a86Adversarial review — complete
Review loop (security gate, so both fork + Codex per rotation, reconciled by Fable, then a fix-commit re-review per the #242 discipline):
GET /api/graphics-elements?refresh=truewrites to the DB), plus lock-race→422-not-409, a fast-completion screen race, machine-key response missingno-store, and anAuthSessiontype-vs-wire mismatch.aa86fb6fbackend +071f339aweb): graphics-refresh POST-ified (GET now pure-read), typedLockedError→409,onPlayphase-promotion on POST-200,Cache-Control: no-storeon the machine key,AuthSessionoptional fields. Plus a CI-gate fix (2e056a12):update-openapi.shnow builds before the doc-gen target (the new #303 api-docs gate hit a latentdeps.jsonfailure — this PR was the first to change the/apisurface and exercise it).LockedError, single call site).Live E2E (fresh instance, headless): 5/5 flows PASS — setup-claim → login → machine-key screen (revealed key ==
$CONFIG/api.key,no-storepresent) → CSRF gating (403 withoutX-CSRF, 204 with) → oldGET playback.m3u8→ 404 → logout/lockout safety.CI: all 7 checks green on
2e056a12.Review-verdict: MERGEABLE @ 2e056a12c1d8c7f45ecae44cd6de9655a4eef29b
2e056a12c1tobe9cf5f381Re-anchoring the verdict after rebasing onto the latest
main(past #315 + the parallel-session CI chores). The rebase is content-identical to the reviewed2e056a12— main's additions were disjoint (docs/scripts/migration-smoke); no code or reviewed surface changed. Full CI is green on the new head.Review-verdict: MERGEABLE @
be9cf5f381