PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610
4.4 KiB
4.4 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| security.fail-closed-api-auth | 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292) | active | 2026-07-12 | none | none | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285 | `docs/api-conventions.md` §5; `ApiControllerSecurityTests` |
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
Owner decisions (confirmed this session): single API key (no read/write split), and
Api:RequireKeyForReads defaults true (the whole /api surface requires the key). This does
not affect Jellyfin/streaming: /iptv/* (playlist/guide/streams/logos) and /artwork/* are outside
the filter's /api scope and keep their own optional access-token; only the management API the SPA talks
to is gated.
- Fail-closed writes (#280, S1). The empty-key "open" branch is deleted; there is no open mode. New
IApiKeyProvider(ErsatzTV/Services/ApiKeyProvider.cs, singleton, resolved once at startup) yields a never-empty key:Api:WriteKeyif set, else a key persisted atFileSystemLayout.ApiKeyPath(/config/api.key,0600, path logged not value), else a generated 256-bit hex key. Every mutating/apirequest now requiresX-Api-Key. - Sensitive-read tier (#282, S3/S5). Reads are gated by
Api:RequireKeyForReads(default true) OR a new[RequiresApiKey]marker (mirror of[SkipApiKeyAuthorization]) applied toTroubleshoot/Logs/Settings/Maintenance, so that tier stays gated even if an operator opts reads open.OPTIONSpreflight is exempt (CORS middleware owns it).ApiControllerSecurityTestsasserts the tier reflectively. - Delete dead non-
/apimutation surfaces (#281, S2).SortController(POST media/collections/{id}/items, dead Blazor SortableJS residue — the SPA usesPUT /api/collections/{id}/custom-order) andAccountController(POST account/logout, dead OIDC) bypassed the key because they sat outside/api. Removed rather than guarded. - CORS opt-in (#284, S6).
AllowAnyOrigin/Method/Headeris replaced by theApiCorspolicy: an exact-origin allowlist fromApi:CorsAllowedOrigins(semicolon list) that permitsX-Api-Key/If-Matchand exposesETag; with no origins configured there is no cross-origin access (the SPA is same-origin). - ForwardedHeaders trust + scanner loopback (#285, S7/S10).
GET /api/maintenance/gc→POST(crawler-triggerable GC; spec regenerated).ForwardedHeaderstrust is configurable viaForwardedHeaders:KnownProxies/KnownNetworks— unconfigured preserves the current trust-all behavior but logs a warning (flipping the default to loopback-only would break reverse-proxy scheme/host detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network).ScannerControllergains[LocalhostOnly](the scanner always calls back overhttp://localhost:{UiPort}/api/scan/...), which is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock.search/all-itemsDoS-paging is deferred (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth exposure is already closed by read-gating). - SPA (
web/). The client sends the stored key (ctv-api-key) on every method (not just mutations); a new keyless API Key screen (/app/api-key) lets the user paste the generated key, and a shell-level banner points there on any 401. See spa-conventions §5e. First-run/upgrade UX: with reads gated by default, the SPA shows no data until the key (from/config/api.key) is entered — an intended consequence of the strict default.
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and /api/v1
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, search/all-items
paging, per-key rate limiting.