Timothy's refinement: the between-releases consolidation floor triggers on the file's
READ COST — its line count, i.e. the context an agent burns reading the log — not entry
count. The decisions-guard CI job now emits a non-blocking ::warning:: once decisions.md
exceeds 1800 lines (the point past which it no longer fits one default 2000-line agent
Read). Documented in the decisions.md header, the H9 entry, and ci-cd.md (job description
+ release-checklist note). Touches committed H9 lines, hence [decisions-edit].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review (PR #305, MERGEABLE) nits:
- H3: `grep -iE` so a root `Screenshot.PNG` is caught too (was lowercase-only).
- decisions-guard.sh: comment the trailing-newline assumption (dropping the final
newline would make git render the next append as a last-line modify -> false-block;
self-correcting via [decisions-edit], .editorconfig enforces the newline).
- docs: clarify CI is PR-wide (`range`) vs Husky per-commit (`staged`) — shared
detection logic, deliberately different granularity; local hook is the stricter gate.
Replaces the slightly-overstated "can't drift" wording. Touches the committed H9
decisions.md entry, hence the [decisions-edit] token.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
decisions.md header now documents the enforced append-only rule, the `[decisions-edit]`
escape, mark-and-keep supersession (banner on the reversed entry + `(superseded)` TOC
tag), and consolidation at each release. ci-cd.md documents H3/H9 hooks + the
decisions-guard job and adds a "consolidate decisions.md" step to the release checklist.
New decisions.md entry records the H9/H3 mechanization. The header edit modifies existing
lines, so this commit carries the [decisions-edit] token — the guard working as designed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 2 hook H6: derive merge-consent from state instead of memory. An
issue's ## Done-when checklist (issue body) becomes the machine-readable
source of truth for whether its PR may merge — the structural fix for the
queue-drift #303 tracks (status was living in append-only prose).
- pretooluse-merge-consent.sh (Claude PreToolUse on mcp__gitea__
pull_request_write): a merge is ALLOWED only when the PR's CI is green
AND every ## Done-when box on the linked issue (fixes #N) is ticked;
DENY on an unticked box / red CI; ASK (human prompt) when state isn't
derivable (no linked issue, no section, no creds, Gitea down). Docs/
process-only PRs exempt.
- .husky/pre-push -> prepush-donewhen.sh: fail-open backstop for a direct
`git push origin main`; blocks only on a positively-proven unticked box.
Gitea auth from env only (ETV_GITEA_BASICAUTH / ETV_GITEA_TOKEN,
ETV_GITEA_URL) — nothing committed; without creds the gate degrades to
today's manual confirmation, never a silent pass. Non-breaking rollout:
until issues adopt ## Done-when the merge hook simply asks.
Pipe-tested: non-merge->allow, no-creds->ask, docs-only->allow, checklist
parser (unit), linked-issue extraction, and a live end-to-end block path
(temp Done-when on #303 -> exit 1 -> restored). Docs: CLAUDE.md Task
Completion Protocol + decisions.md entry. Refs #303.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a blocking `api-docs` CI job: when a PR diff touches the API surface
(ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**) it rebuilds the
generated artifacts from source — v1.json, v1.d.ts, endpoint-index.md —
and fails if any is stale in the diff. Mechanizes the "docs-update in the
same PR" rule for the API contract (docs-reminder stays a non-blocking
route-parity nudge).
Path-gated INSIDE the job (per-step `if:` on a detect output), not via a
top-level `if:`, so the check always reports a status on every PR and is
safe as a required check: API-free PRs skip the dotnet/node setup + regen
and pass trivially.
Verified the gate reproduces the committed baseline: a fresh build
regenerates v1.json byte-identical to HEAD (incl. all 244 auth
security/401 blocks). The only footgun is local — update-openapi.sh runs
dotnet-getdocument against the already-built assembly, so a stale bin/
emits a stale spec; api-conventions.md §5 now flags "build first". CI is
immune (fresh checkout has no bin/).
Docs: api-conventions.md §5 (two-place CI enforcement + stale-assembly
note), decisions.md (new entry). Refs #303.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix-commit re-review (cold fork) caught that e383c253 tracked a machine-specific
absolute-path symlink `web/node_modules -> /Users/.../web/node_modules` (created
for the eslint pre-push hook). It slipped past `.gitignore` because the
`web/node_modules/` trailing-slash pattern matches only a directory, not a
symlink; a real node_modules dir (a copy) would have been ignored. Untrack it and
tighten the ignore to `web/node_modules` (matches symlink or dir) so it can't
recur.
Also tightens the §7a / decisions.md "they already catch" phrasing (LOW review
nit): only BuildPlayoutHandler catches; PlayoutTimeShifter is insulated by running
solely on the background worker, never the request path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in the #303 process/rigor hooks + docs. Only conflict-free overlap was
docs/decisions.md (main added the ## Index TOC); reconciled by adding the #295
auth entry to the index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 2 hook H7: never commit/merge inside a sibling worktree another
session created (burned us on #289 path-leak + the plumbing-merge
workaround). Ownership = a per-session .claude-worktree-owner marker:
- posttooluse-worktree-marker.sh stamps a worktree with session_id on
`git worktree add` (parses the <path> arg past -b/-B/--reason flags).
- pretooluse-worktree-guard.sh denies `git commit`/`git merge` whose
effective dir (resolves `git -C <p>` and leading `cd <p> &&`) is a
worktree whose marker names a DIFFERENT session. Fail-open: no marker,
unparsable, or own session -> allow. Main tree + pre-convention
worktrees are never marked, so unaffected.
Also completes Wave 1's rollout, which committed pretooluse-bash-guard.sh
but left .claude/settings.json and the agent-ram/nav-guard hooks
untracked (so nothing was actually wired). Adds the settings.json that
registers all five hooks (PreToolUse Bash x2, nav, Agent; PostToolUse
Bash) + the .gitignore worktree-marker line, screenshot-scratch rules,
and the decisions.md TOC left uncommitted last session.
All hooks pipe-tested (7 guard cases + 6 marker cases). Refs #303.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent adversarial review (cold fork + Codex) of the first cut converged
on one real miss and two boundaries to document.
- **ErasePlayoutHistoryHandler** (HIGH, both reviewers): modifies Playout ROOT
scalars (Seed/Anchor/OnDemandCheckpoint) *without* bumping Version, inside an
explicit transaction with no try/catch, so it 500s on a concurrent bump —
reachable via POST /api/playouts/{id}/erase-items-and-history. My first sweep
filtered on "Version-bumpers + deletes"; the true exposure surface is "any
handler leaving a versioned root Modified/Deleted", so this slipped through.
Now routes through SaveChangesForcingVersion (+ a non-vacuous through-handler
test that exercises the explicit-transaction path). Re-swept with the correct
filter: ErasePlayoutItems (AsNoTracking + ExecuteDelete children only) and
ResetAllPlayouts (read-only + enqueue) are NOT exposed.
- **Background build/time-shift Playout-scalar writers** (BuildPlayout via
PlayoutBuilder, PlayoutTimeShifter): token-guarded too, but intentionally left
on plain save — they already catch (build-failure, not 500), and force-writing
would persist output built from stale config (the concurrent config bump already
enqueues a rebuild). Documented as a deliberate boundary, not a gap.
- **Item-add index collision** under force-write: documented as an accepted
Phase-1 effect (non-corrupting, self-correcting; reload-recompute refinement
is a #197 candidate).
Also corrects the docs' "every Version bumper" framing to the true filter and the
test docstring's over-broad non-vacuity claim. Full ErsatzTV.Tests green (1483).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix-commit re-review confirmed the 1st-round fixes resolved and caught a 2nd round:
- HIGH — env-seed vs. setup race: an attacker could claim admin in the startup
window before LocalAdminSeedService runs, and the seed's insert would then be
swallowed (attacker credential persists, defeating env recovery). Fixed
structurally: the setup-claim endpoint is CLOSED (409) whenever
Auth:LocalAdmin:Password is configured — the env seed owns the credential, so
there's no claim to race (also strengthens the setup-claim TOFU posture).
Config.setupRequired reflects it.
- LOW — a concurrent setup race-loser now returns 409 (not 422); ClaimLocalAdmin's
DbUpdateException catch re-checks existence and rethrows genuine/transient DB
errors instead of masking them as "already configured".
- MEDIUM (accepted, documented) — two simultaneous authenticated password changes
are a non-serializable lost-update; accepted for a single-admin system
(self-healing via re-login, implausible timing).
+3 AuthController tests (env-seed closes setup / setupRequired gating). Full
ErsatzTV.Tests green (1506); no generated drift. Docs updated.
Refs #295
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live E2E found that replaying a pre-logout cookie still authenticated (200, not
401): SignOutAsync only clears the CLIENT cookie, but the stateless encrypted
cookie ticket stays valid server-side because its security stamp is unchanged —
a captured cookie was replayable after logout until ticket expiry.
Fix: logout now rotates the local-admin security stamp (RotateLocalAdminSecurityStamp),
so every outstanding local session (old stamp) fails OnValidatePrincipal on its
next request. For the single admin this is "log out everywhere". Gated on an
authenticated local session so an unauthenticated caller can't force-revoke the
admin. OIDC sessions (no stamp) are unaffected; SignOutAsync still clears the
client cookie for UX.
+2 handler tests (rotate-when-configured / no-op-when-unconfigured). Auth suite
green (21). Docs: decisions.md note updated.
Refs #295
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent review (cold fork = MERGEABLE-WITH-NITS; Codex = BLOCKED, caught
concurrency defects the fork missed). All actionable findings folded in:
- HIGH (Codex) atomic first-claim-wins: ClaimLocalAdmin now writes the three
credential rows in ONE transaction guarded by the unique ConfigElement.Key
index (lost race -> DbUpdateException -> 409), so concurrent claims can't
produce a mixed-state credential.
- HIGH (Codex) consistent login snapshot: VerifyLocalAdminLogin reads hash+stamp
in one query and drops rehash-on-verify, so a login racing a password change
can't capture a stamp newer than the hash it verified (concurrent change ->
old password fails, or the issued cookie carries the pre-change stamp ->
revoked next request).
- MEDIUM (Codex) env-seed migration race: LocalAdminSeedService is now a RunOnce
BackgroundService that awaits SystemStartup.WaitForDatabase (the migrator is a
BackgroundService; registration order didn't guarantee the schema) + try/catch.
- MEDIUM (fork M1) ForwardedHeaders: reverted the strict-opt-in flip — it would
regress /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme) behind a
proxy without KnownProxies. Kept #285 behavior; KnownProxies still recommended.
- LOW (Codex/fork) require X-CSRF on /api/auth/logout + /password (the
[SkipApiAuthorization] surface isn't covered by the filter's CSRF check;
closes forced-logout CSRF).
- ChangeLocalAdminPassword also writes hash+stamp atomically. Input length caps
on username/password.
Deferred with a tracked gate: MEDIUM (Codex) side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered -> #301, gates PR2
(latent in PR1: the SPA still uses the machine key).
Verify: full ErsatzTV.Tests green (1501); no OpenAPI/generated drift. Docs updated
(api-conventions §9, decisions.md).
Refs #295#301
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Activating #253's `Version` as an `IsConcurrencyToken` made EF guard every
UPDATE *and DELETE* of a versioned root with `WHERE Version=@orig`, so any
writer outside the If-Match contract that saves via plain `SaveChangesAsync`
throws an unhandled `DbUpdateConcurrencyException`->500 when a replace-all
editor bumps the row in its narrow load->save window (ordinary two-tab UI).
A completeness sweep (grep every `Version` bumper + every root delete, not
just the handlers PR3's close note named) found 17 exposed writers, all now
routed through `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write: adopt the stored token and retry; rethrow only on genuine
row-deletion):
- 9 versioned-root delete handlers (a delete has no ETag to rotate -> force
only, no bump)
- UpdateProgramScheduleHandler (bumps then saved plainly - the ProgramSchedule
case PR3 only suspected; its post-commit query/enqueue also moved to
CancellationToken.None per section 7b)
- 7 item add/remove bumpers PR2 left on plain save:
Add/DeleteProgramScheduleItem + Add{Items,Movie,Show,Season,Episode}ToPlaylist
Force-write (not 412) is correct: these endpoints take no If-Match, so an
unconditional delete/edit should win. No API contract change (no new response
codes) -> no OpenAPI regen.
Still deferred to #197 (cross-editor ETag rotation only, not a 500): the
non-bumping config siblings + the scanner-shared Add*ToCollection family.
Tests: RootWriterForceVersionTests races a bump *through the handler* via a
pre-tracked context (non-vacuous - reverting a handler to plain save fails the
test, verified) for the Option-delete / Either-delete / bump+update shapes,
plus the genuine-conflict rethrow branch and an explicit negative control
proving the plain-save path throws. Full ErsatzTV.Tests green (1482).
Also strips a pre-existing UTF-8 BOM from the touched handlers to satisfy the
.editorconfig `charset=utf-8` rule the pre-commit format hook enforces.
Docs: api-conventions section 7a (fan-out completeness) + decisions.md entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lore had grown into a changelog (~40 verbose bullets). Removed bullets
whose substance lives in docs/decisions.md or api-conventions §7a/b/c
(#251 post-commit None, #252/#259 reconcile, #253 PR3/PR4 concurrency, addTo
layer, DTO nullable facts, disabled={saving}) and dead Blazor-parity process
notes (parity verdicts, screen!=parity, unwired-endpoint gaps, #205/#206
docs-staleness). Condensed the survivors. Added a scope banner: this section
is standing workflow/orchestration lore only; engineering decisions go in
decisions.md, never here (they duplicate and drift).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recent session pickups re-did finished work (thought closed issues were
still open). Root cause: DONE/OPEN status lived in append-only prose (arc
inline markers + each comment's 'Recommended next'), which lags real issue
state — worst across parallel sessions narrating each other's merges.
Kickoff prompt fixes:
- SOURCE OF TRUTH = live Gitea state, never prose; verify OPEN (issue AND
milestone) before claiming.
- Gate/frontier defined structurally (lowest-numbered open arc item), not
hardcoded to an issue number (an earlier pass re-planted the bug by
hardcoding '#197 cluster'/'#91b milestone CLOSED').
- Label-based ranking: arc order -> gate(review label/milestone) -> priority.
- Pick order across arc-frontier / priority-pickup / audit pools.
- Bound the comment read (~6 newest); MCP-down REST fallback; #237 canonical.
- New lore bullet capturing the drift root cause + structural cure.
Companion edits to tracker #237 body (prune arc status -> 'Done (history)'
section) made live; noted in a session comment there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the ratified #295 design (PR1, server-only, backward compatible). The
/api surface now accepts a valid X-Api-Key (machine) OR an authenticated session
(browser cookie, local login or OIDC), gated by the evolved ApiAuthorizationFilter
(renamed from ApiKeyAuthorizationFilter; same fail-closed EndpointRequiresKey
predicate). Machine/key behavior is byte-identical and the SPA keeps working via
its stored key — the SPA login flow lands in PR2.
- ApiAuthorizationFilter: key-first (CSRF-immune) then session; session-authed
mutations require the X-CSRF header (403 otherwise). Attributes renamed
[RequiresApiKey]->[RequiresAuthentication], [SkipApiKeyAuthorization]->[SkipApiAuthorization].
- Cookie scheme ctv-session always registered (Lax/SameAsRequest/14d sliding, 401 not
redirect for /api); OIDC handler revived when configured (profile scope, userinfo,
auth-method claim); UseAuthentication/UseAuthorization/UseRateLimiter revived in the
legacy MapWhen branch.
- Local admin = single credential in ConfigElement rows (username / PBKDF2 hash via
Microsoft.Extensions.Identity.Core / rotating security stamp) — NO DB migration.
Password change rotates the stamp; CookieSecurityStampValidator revokes stale local
sessions. Env-seed recovery (Auth:LocalAdmin:*) via LocalAdminSeedService.
- AuthController /api/auth/{config,session,setup,login,logout,password} + browser-nav
GET /auth/oidc/login; excluded from OpenAPI (machine-audience spec). Per-IP rate limit
on login/setup/password; dummy-hash verify (no user enumeration).
- ForwardedHeaders now strict opt-in: X-Forwarded-* ignored unless KnownProxies/Networks
configured (rate-limiter IP + cookie-Secure integrity). Deployment: operators behind a
proxy must set ForwardedHeaders:KnownProxies.
- Tests: session/CSRF filter cases + 17 Application/Auth handler tests; full ErsatzTV.Tests
green (1499). No OpenAPI/generated-artifact drift.
- Docs: api-conventions section 9 rewritten; decisions.md entry (supersedes #206 inert-OIDC note).
Refs #295#197#206#58
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.
SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.
Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.
Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mint ChannelDetailResponseModel (faithful detail DTO exposing the raw editable
field set the channel editor reads: raw FFmpegProfileId/WatermarkId/FallbackFillerId
ids, the mode enums, logo, playoutCount, id) and route GetById/Create/Update through
it, replacing the lean list ChannelResponseModel that resolved the profile to a name
and dropped the editable ids (a functional regression for draftFromChannel). The lean
ChannelResponseModel stays unchanged for GET /api/channels. webEncodedName dropped
(SPA never reads it). Logo is mirrored as a Core ChannelLogoResponseModel since the
Application ArtworkContentTypeModel can't be referenced from Core.
Repoint the hand-written SPA client aliases now that the VMs are gone from the schema:
Channel -> ChannelDetailResponseModel, MediaCollection/SmartCollection -> *ResponseModel,
ProgramSchedule -> ProgramScheduleResponseModel. Fix#288 honest-nullability test fallout
in search.test.ts (null -> [] for now-non-null id arrays). Include the already-on-disk
playouts.ts WithDayNames removal and regenerate v1.json + v1.d.ts + endpoint-index.md
(authoritative final regen; the reset endpoint's {channelNumber}->{id} re-key surfaces
in the generated docs and the OpenApi error-contract test).
Refs #288#197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (diagnosed from run logs 513/515/516): the EF migration-integrity
job's "MySql apply all migrations to a fresh DB" step flakes when two migration
jobs land on the SAME runner host at once — each `services: mysql:8.4` container
starves the other, so the 787-migration replay either exceeds MySqlConnector's
30s default command timeout ("Command Timeout expired", run 513 on ci-runner) or
has its connection dropped mid-replay ("MySqlEndOfStreamException", run 516 on
bumblebee-runner). It's pure infra contention: `has-pending-model-changes` (the
model check) passes both providers, and the identical tree passes on a quieter
host (run 515). Both runners have both passed and failed — not one bad runner.
Fix (runner-agnostic, repo-owned workflow only — no runner-host change needed):
- Raise `DefaultCommandTimeout` to 300s in the MySql connection string.
- Wrap the apply in a 3× retry that resumes from `__EFMigrationsHistory` (EF
commits each migration in its own transaction, so an interrupted one rolls back
and the retry continues). A real migration failure fails on every attempt, so
the retry can't mask a genuine problem.
Docs: ci-cd.md migration-integrity section documents the contention + retry.
Refs #13#236
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-review LOW (defense-in-depth): the serve path derived the Content-Type
from the stored file via Winista but only defaulted application/octet-stream on
a NULL sniff. A cache file whose bytes are HTML — a legacy entry poisoned before
the upload-sniff landed, or a hypothetical image/script polyglot — could still be
sniffed as text/html and served renderable (nosniff does not stop an explicitly
declared text/html). Clamp the sniffed type to ImageContentTypes.IsAccepted,
serving application/octet-stream for anything else, so the serve path can never
emit a renderable non-image type regardless of what bytes are on disk.
Refs #283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S4 stored-XSS + S9 upload-size DoS from the #197 cold API review.
The artwork path trusted client-supplied content types at both ends: upload
validated only the declared multipart Content-Type (never decoded the bytes),
and serving reflected a client `?contentType=` straight into the response
Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks).
Chain: upload <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.
- Upload: derive the content type from the bytes via SkiaSharp SKCodec
(header-only, no decode -> no decompression-bomb path); reject non-images 422.
New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
the handler always sniffs the file, defaulting application/octet-stream.
ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
bodies during read (controller file.Length check kept as friendly-error backstop).
Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.
Refs #283#197#66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend of #197 Bundle A (auth posture). Owner decisions: single API key;
Api:RequireKeyForReads defaults true (whole /api surface gated; /iptv streaming
+ guide unaffected — outside the filter's /api scope).
- #280 S1: writes are fail-closed. New IApiKeyProvider resolves the key once
(Api:WriteKey config, else persisted /config/api.key, else a generated 256-bit
key written 0600). The empty-key open branch is gone; there is no open mode.
- #282 S3/S5: reads under /api require the key when Api:RequireKeyForReads (default
true) or the endpoint carries the new [RequiresApiKey]. Applied [RequiresApiKey]
to Troubleshoot/Logs/Settings/Maintenance so the sensitive tier stays gated even
if reads are opened. OPTIONS preflight is exempt.
- #281 S2: delete SortController (dead Blazor SortableJS residue; SPA uses PUT
/api/collections/{id}/custom-order) and AccountController (dead OIDC logout) —
both non-/api persistent surfaces that bypassed the key.
- #284 S6: replace CORS AllowAll with an opt-in exact-origin allowlist
(Api:CorsAllowedOrigins; permits X-Api-Key/If-Match, exposes ETag). Default is
no cross-origin (SPA is same-origin).
- #285 S7/S10: gc GET->POST (spec regenerated); ForwardedHeaders trust configurable
via ForwardedHeaders:KnownProxies/KnownNetworks (warns when unrestricted);
ScannerController gains [LocalhostOnly] (scanner always calls back over localhost).
Filter unit tests rewritten for fail-closed + read-gating + tier + OPTIONS;
ApiControllerSecurityTests assert the sensitive tier + scanner-loopback reflectively.
search/all-items paging deferred (SPA add-all coupling) — exposure closed by read-gating.
Refs #197#280#281#282#284#285
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundle A SPA slice: the /api surface is now gated behind X-Api-Key on
every request (reads too, RequireKeyForReads defaults true), so a wrong/
missing key 401s everything.
- #282: send X-Api-Key on ALL requests when a key is stored, not only
mutations (removed the mutatingMethods split in api/client.ts).
- #280: new keyless API Key screen (/app/api-key, System nav) that reads/
writes only localStorage via auth.ts and never calls /api, so it works
on a fresh install where every read 401s. Masked key state, Save/Clear,
points at server-generated /config/api.key.
- 401 UX: client emits one app-wide unauthorized signal (auth.ts
notify/subscribeUnauthorized); a shell-level UnauthorizedBanner points
the user at the API Key screen. DRY, no per-screen 401 branches.
- Tests: inverted the GET header assertion (key now sent on reads), added
no-key and 401-signal client tests, auth signal tests, and screen +
banner tests. spa-conventions.md §5e documents the new seams.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to PR #279 — the adversarial diff review flagged that adding
baseline security headers to every response is an operational-behavior
decision worth a decisions.md entry. Records the SecurityHeadersMiddleware
placement + the deliberate CSP/HSTS deferral to the #197 posture design,
plus the constant-time key compare and playout paging clamps.
Refs #197.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Timothy reversed the version-pin decision: prod's media-servers compose now
follows the floating :prod tag, redeployed by Komodo Global Auto Update. The
bump-prod-compose job (#275) rewrote a :<version> pin, which would flip :prod ->
:26.8.0 on the next release — remove it. docs/ci-cd.md reconciled to the :prod
model (+ flags the open caveat: verify Global Auto Update runs the #553
pre-deploy backup, else releases deploy without a backup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the id-based reconcile tests to ReplaceProgramScheduleItemsReconcileTests:
reorder moves state with the logical item (the non-vacuous core — proven to fail
under forced-positional), insert-in-middle, delete-unreferenced, unknown-id→422,
duplicate-id→422, and stale-version+unknown-id→412 (412 precedes 422, §7c). The
GET→map→PUT lossless round-trip now round-trips r.Id so it exercises id-mode.
Threads the new int? Id through all command/wire construction sites in tests.
Docs: api-conventions §7c (stable child identity + the deliberate #2-#5 positional
asymmetry) and a decisions.md entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-pin-to-prod job designed on the unmerged `ci/auto-bump-prod-compose`
branch (3d6ac883) never landed on main — so v* releases (v26.5.0, v26.6.0) did
NOT auto-bump the server-management compose pin (it sat at 26.5.0). The docs
(homelab-docs Docker/ErsatzTV.md, ci-cd.md) described the auto-bump as if live.
Restore the job verbatim (its credentials already exist: the `ersatztv-ci-deploy`
write deploy key, id 5, on server-management + the SERVERMGMT_DEPLOY_KEY secret
here). On a v* tag, after the test-gated image builds, it rewrites the pinned
`ersatztv:<version>` tag in docker/bumblebee/stacks/media-servers/compose.yaml
and pushes to server-management `master` → the Gitea->Komodo webhook redeploys
prod with a pre-deploy backup. Idempotent (no-op if already pinned).
docs/ci-cd.md updated to match (release procedure + the stale ":prod pin" claim,
which was actually an immutable :<version> pin since 2026-07-07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ChicoryTV React SPA (web/, served at /app) now has full parity for every
route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is
deleted. This is the milestone-capping removal of #91 phase (b).
Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs),
Validators/** (10 edit-VM validators), App.razor, _Imports.razor,
Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept),
wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests.
Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder,
AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer
registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization
middleware (per the #206 auth-posture sign-off), MapBlazorHub, and
MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still
co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect
middleware. Replaced the _Host fallback with a catch-all (MapFallback ->
302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204.
Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth
is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter.
Pruned 9 now-unused packages (all verified zero remaining consumers) from
Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar,
Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the
now-dead #25 razor-Sonar NoWarn.
LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects
(SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban.
Tests: Release build clean; full solution suite green. Updated Startup
source-text tests + added regression coverage that Blazor wiring is gone, the
catch-all is wired, and all 14 media-sources routes redirect.
Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry),
CLAUDE.md, contributing.md, README.md all updated in this PR.
Rollback: tag blazor-final is cut on pre-merge main as the first merge action.
Part of #91.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>