#616 filed three MCP/API paging traps. Two were real; one was not, and one was already half-fixed on main. Verified each against the code before changing it. REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as "1-based page number" while every paged controller defaults it to 0, floors it with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that trusted the description started at page 1 and silently lost the first page: no error, just a short set that reads as data loss rather than an off-by-one (it cost #487 a verification pass). Fixed in the description rather than by making the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean two different things on two surfaces a reader reads together. NOT REAL — "pageSize caps the page but the offset honors the requested value". Not reproducible on any endpoint. Every controller clamps before passing, every handler skips by the clamped size, and GetCollectionItemsHandler re-clamps defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item collection returning 4 items) is exactly correct 0-based behaviour at the clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table states this. Pinned by test rather than "fixed". ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three days before #616 was filed; the report was measured against prod, which runs an older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still lacked it, so channelId is added there (additive) and the reset_channel_playout argument now names the trap: the id spaces overlap numerically, so passing a playout id silently resets a different channel and returns a plausible 202. Tests, both mutation-verified (each fails when its fix is reverted): - ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description, with a non-empty guard so it can't pass vacuously over an empty tool set. - GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the offset derives from the clamped pageSize (page 1 at pageSize=500 returns items 101-150; the mutation that honors 500 returns an empty page). Docs: new decision record api.paging-zero-based (catalog regenerated), the api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI v1.json + web/src/api/generated/v1.d.ts regenerated for the added field. fixes #616 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
864 lines
70 KiB
Markdown
864 lines
70 KiB
Markdown
# API conventions — "Add a REST endpoint" checklist
|
||
|
||
Purpose: a precise, file-path-and-exemplar checklist for adding or changing a `/api/*` endpoint on
|
||
the ErsatzTV fork, so an agent with no prior context in this repo can do it correctly on the first
|
||
pass. **Update this doc in the same PR that changes any convention below.**
|
||
|
||
This is a companion to `docs/contributing.md` (general CQRS/LanguageExt/testing conventions) and
|
||
`docs/ci-cd.md` (build/release pipeline) — read those first for anything not API-specific.
|
||
|
||
## 1. Controller shape
|
||
|
||
Controllers live in `ErsatzTV/Controllers/Api/*.cs`, one per domain (e.g. `TemplateController.cs`,
|
||
`BlockController.cs`, `LogsController.cs`). Every action needs this attribute set:
|
||
|
||
- `[ApiController]` on the class.
|
||
- `[HttpGet/Post/Put/Delete("/api/v1/...")]` on the action, with `Name = "..."` on at least the
|
||
primary GET (used by the SPA's OpenAPI-generated client and by route-assertion tests).
|
||
**The route is versioned and absolute** (`/api/v1/...`, leading slash, full path on the method attribute —
|
||
no class-level `[Route]`). This is enforced: `ApiRouteVersioningTests` (sibling of `ApiControllerSecurityTests`)
|
||
reflects over every `[ApiController]` action in `Controllers.Api` and fails if an effective route doesn't match
|
||
`^/api/v\d+/`. The **only** controllers with a class-level `[Route]` are the two whose ~all actions share a
|
||
parametrized prefix — `ScannerController` (`[Route("/api/v1/scan/{scanId:guid}")]`) and
|
||
`ScriptedScheduleController` (`[Route("/api/v1/scripted/playout/build/{buildId:guid}")]`) — and there the
|
||
method segments are relative (`[HttpPost("progress")]`). A browser-nav endpoint deliberately outside `/api`
|
||
(`AuthController`'s `GET /auth/oidc/login`) is out of scope for the versioning rule. See `docs/decisions.md`
|
||
2026-07-13 (#286) for the versioning contract (additive-only after freeze; the legacy `/api/*`→`/api/v1/*`
|
||
in-pipeline rewrite in `ApiVersionRewriteMiddleware`).
|
||
- `[Tags("Domain")]` — groups the endpoint in Swagger UI / the SPA's generated client namespace.
|
||
- `[EndpointSummary("...")]` — one-line description; optionally `[EndpointDescription("...")]` for
|
||
more detail.
|
||
- `[EndpointGroupName("general")]` — **REQUIRED**. Endpoints without it are excluded from the
|
||
generated OpenAPI document entirely (verified: every action in `TemplateController.cs` and
|
||
`LogsController.cs` has it; there is no other endpoint group in use).
|
||
- `[ProducesResponseType(typeof(X), StatusCodes.StatusYyy)]` for every status code the action can
|
||
actually return (200/201/204 success, plus 404/422 as applicable) — this drives the generated
|
||
TypeScript response types on the SPA side.
|
||
|
||
Exemplars:
|
||
- **Full CRUD controller**: `ErsatzTV/Controllers/Api/TemplateController.cs` — group CRUD +
|
||
item CRUD + copy, all four verbs, `ToCreatedResult`/`ToErrorResult` usage.
|
||
- **Paged GET with clamped params**: `ErsatzTV/Controllers/Api/LogsController.cs` —
|
||
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
|
||
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
|
||
input for page math. **`pageNum` is 0-based** across the whole surface (the first page is `0`) and
|
||
the offset is always derived from the *clamped* `pageSize`, so an over-large `pageSize` yields
|
||
narrower pages — it never widens the offset. Say "0-based" in the description of any paging
|
||
parameter you expose, including on wrapper surfaces like the MCP tool catalog: describing it as
|
||
1-based makes a caller skip the first page silently, which reads as data loss rather than as an
|
||
off-by-one (ersatztv#616). See `api.paging-zero-based`.
|
||
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
|
||
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
|
||
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
|
||
`sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for
|
||
any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad
|
||
query string from ever producing an error response for a read-only listing.
|
||
|
||
## 2. DTOs: where they live and their nullable context
|
||
|
||
- **Response DTOs**: `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs`. They are `record`s that mirror
|
||
the shape of the Application-layer ViewModel for that domain — **never expose a VM type directly**
|
||
from a controller. Convert framework types the SPA can't consume as-is (e.g. `CultureInfo` →
|
||
its name string).
|
||
- As of #288, **all** response-model files under `ErsatzTV.Core/Api/` carry `#nullable enable`
|
||
(including the two enum wire-mirrors `Settings/XmltvBlockBehavior.cs` / `XmltvTimeZone.cs`), so the
|
||
pragma is now universal, not "most". The project sets `<Nullable>disable</Nullable>`
|
||
(`ErsatzTV.Core/ErsatzTV.Core.csproj`), under which a non-null `string` member emits a spurious
|
||
`nullable: true` in the schema — so **add `#nullable enable` at the top of every new response-model
|
||
file** and mark a member `?` only when the mapper can actually emit null. `FFmpegProfileResponseModel.cs`
|
||
is no longer an exception.
|
||
- **Raw-VM wrapping (#288).** The last controllers returning Application ViewModels directly were
|
||
wrapped, so no `/api/*` action returns a `*ViewModel` type anymore: `CollectionController` →
|
||
`MediaCollectionResponseModel` (`Id, Name, CollectionType, UseCustomPlaybackOrder` — drops the
|
||
`MediaCardViewModel` scaffolding and the header-only `Version`), `ScheduleController` →
|
||
`ProgramScheduleResponseModel` (VM minus `Version`), `SmartCollectionController` → the existing
|
||
`SmartCollectionResponseModel`, `ResolutionController.GetResolutionByName` → `ResolutionResponseModel`,
|
||
and the **channel detail** GETs/writes → **`ChannelDetailResponseModel`** (the full editable field set
|
||
the SPA channel editor needs — distinct from the lean list `ChannelResponseModel`; drops only the
|
||
derived `webEncodedName`). When wrapping, expose exactly what the client reads: a leaner projection is
|
||
right for a list, a faithful detail projection for an editor.
|
||
- **Request DTOs**: `ErsatzTV/Controllers/Api/Requests/*Request.cs`. No `#nullable enable` pragma —
|
||
match the existing files (e.g. `CreateFFmpegProfileRequest.cs`, `ReplaceScheduleItemsRequest.cs`).
|
||
A request record typically carries a `ToCommand()` (or `ToCommand(int parentId)`,
|
||
`ToReplaceCommand(int index)`) method that maps it to the Application-layer command type.
|
||
- **`ErsatzTV.Application` has no nullable context** (no `<Nullable>` = C# default `disable` for
|
||
that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable
|
||
annotations to types living there; that's a Core/Api-layer-only convention. A static mapper that
|
||
lives in `ErsatzTV.Application` but returns a Core/Api response DTO with nullable members is fine
|
||
(e.g. `ScheduleItemResponseMapper`) — the nullability lives on the DTO record, not the mapper.
|
||
- **Shared `{id, name}` embeds**: use `ErsatzTV.Core/Api/NamedIdResponseModel.cs`
|
||
(`record NamedIdResponseModel(int Id, string Name)`) when a response DTO needs to embed a list of
|
||
named references (e.g. a schedule item's `watermarks` / `graphicsElements`) rather than minting a
|
||
one-off `(int, string)` record per domain.
|
||
- **Server-declared capability fields.** When the SPA must decide whether an action is possible,
|
||
the server declares it as structured metadata on the response DTO (`{Availability, ...}`) rather
|
||
than the SPA inferring it from a display string. Examples: health-check remediation
|
||
(`api.healthcheck-remediation-dto`) and channel preview capability
|
||
(`ChannelPreviewResponseModel`).
|
||
- **Server-derived rollup verdict + hand-maintained string union**: when a DTO needs a computed
|
||
verdict rather than a raw fact, add a nested response model carrying a `Status` plus a `Faults`/
|
||
detail array, both typed as plain `string`/`string[]` backed by a `const string` class (e.g.
|
||
`ChannelHealthStatus`, `ChannelFault`), **not** a C# enum — keeps the OpenAPI schema a bare
|
||
`string`/`string[]` and lets the SPA hand-maintain its own TS union, exactly like
|
||
`ChannelPreviewAvailability`. Exemplar: `ChannelHealthResponseModel` (`{Status, Faults[],
|
||
PlayoutCount, BrokenSourceItemCount}`) nested as `health` on `ChannelResponseModel` /
|
||
`ChannelDetailResponseModel` — computed **read-time** from the built playout timeline (never
|
||
cached at build time, since the underlying `MediaItem.State` flips on scan) via one bounded
|
||
aggregate query, not per-row N+1. See `docs/decisions.md` → `api.channel-health-object` (#415).
|
||
- **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic
|
||
record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote
|
||
every subtype field to a nullable top-level member on a flat response DTO and pattern-match the
|
||
concrete VM in the mapper. Exemplar: `ScheduleItemResponseModel` (issue #126, see
|
||
`docs/decisions.md` 2026-07-10). Keep the flat DTO's **mutation** fields named 1:1 with the
|
||
matching request DTO so GET→PUT is lossless (guard with a round-trip handler test).
|
||
- **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable
|
||
enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on
|
||
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
|
||
`GET /api/v1/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
|
||
enum value is rejected by model binding (400) — no handler-side guard needed.
|
||
- **Optional bool query param (flag / cache-bust)**: bind `[FromQuery] bool name` (absent → `false`) and
|
||
thread it into the query record with a defaulted parameter so existing callers are unaffected. Exemplars:
|
||
`?deep=` on `POST /api/v1/libraries/{id}/scan` (§3b), and `?refresh=` on `GET /api/v1/health`
|
||
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL
|
||
result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
|
||
2026-07-19, #431).
|
||
- **"Clear to none" vs "inherit" on a coalescing DTO: a typed `clear` enum list, not null.** When a
|
||
create/patch DTO resolves a field as `request.X ?? inherited.X` (e.g. `advanced.X ?? template.X`),
|
||
`null` already means *inherit*, so it cannot also mean *set to none*. Add an optional `clear` field
|
||
typed as a **list of a string enum** naming the fields to force to none — additive, so omitted =
|
||
inherit stays byte-stable for existing clients. Validate that a field is not both set and cleared
|
||
(reject as 422). **Define the enum in `ErsatzTV.Core`** (not the Application command) so
|
||
`Startup.UseStringEnumSchemas` renders it as a string enum in the spec — an Application-layer enum
|
||
shows as a bare `integer`. Exemplar: `CreateChannelFromLineupClearField` on
|
||
`POST /api/v1/channels/from-lineup` (`decisions.md` 2026-07-21, `api.from-lineup-clear-to-none`, #135).
|
||
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
|
||
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
|
||
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
|
||
`HealthCheckResponseModel` (#164) kept flat `string? Link` (still populated) and added
|
||
`Remediation { Kind, Target }` (a nested model with an in-app-route-vs-external-doc kind) plus
|
||
`Brief`. `Remediation.Kind` is a mapped **string** ("ExternalDoc"/"AppRoute"), not a wire enum —
|
||
same pattern as `Status`. See `decisions.md` 2026-07-17 (#164).
|
||
|
||
## 3. Error mapping
|
||
|
||
Central helper: `ErsatzTV/Extensions/ApiResults.cs`. Use these extension methods instead of
|
||
hand-rolling `IActionResult` status codes:
|
||
|
||
| Method | Input | Output |
|
||
|---|---|---|
|
||
| `ToErrorResult()` | `BaseError` | 404 if `NotFoundError`, **412 if `PreconditionFailedError`** (optimistic-concurrency mismatch, §7a), **409 if `LockedError`** (a handler's own lock-acquire lost the race, §3a), else 422 (`ProblemDetails`) |
|
||
| `ToCreatedResult(location, body)` | `Either<BaseError, T>` | `Left` → `ToErrorResult()`; `Right` → 201 + `Location` header |
|
||
| `ToUpdatedResult()` | `Either<BaseError, T>` | `Left` → `ToErrorResult()`; `Right` → 200 + body |
|
||
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left` → `ToErrorResult()`; `Right` → 204 |
|
||
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
|
||
| `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) |
|
||
| `ApiResults.ConflictProblem(title, detail)` | — | 409 `ProblemDetails` directly — for a mutation that races a background operation holding a lock (see §3a) |
|
||
|
||
### 3a. 409 when a mutation races a background lock
|
||
|
||
When an endpoint mutates an entity that a background operation may be actively rebuilding under an
|
||
`IEntityLocker` lock, guard the mutation and return **409 Conflict** (`ApiResults.ConflictProblem`)
|
||
while the lock is held. This mirrors the Blazor UI, which disables the same actions while the lock
|
||
event is live.
|
||
|
||
**This guard is advisory check-then-act, not mutual exclusion.** It narrows the race but does not
|
||
eliminate it: a build already queued can acquire the lock a moment *after* the check passes, and the
|
||
mutation then interleaves with the build anyway. That residual window is accepted where the
|
||
consequences are self-healing (a playout half-mutated during a build is corrected by the next
|
||
rebuild). If an entity's consequences were NOT self-healing, this pattern would be insufficient —
|
||
the mutation would need to actually acquire the lock for its duration instead.
|
||
|
||
Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout`): inject
|
||
`IEntityLocker`, and at the top of every id-keyed mutation (`PUT`/`POST`/`DELETE`) check
|
||
`IsPlayoutLocked(id)` → `ConflictProblem("Playout build in progress", ...)`; add
|
||
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
|
||
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
|
||
- **Fire-and-forget bulk operations don't 409** — `POST /api/v1/playouts/reset-all` stays 202; its
|
||
handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler
|
||
semantics. Only per-id mutations 409. As of #235 the handler returns a `ResetAllPlayoutsResult`
|
||
(`QueuedPlayoutIds` / `SkippedLocked` / `SkippedUnsupported`) and the controller returns the 202
|
||
**with a `ResetAllPlayoutsResponseModel` body** reporting what was queued vs. skipped (locked, or
|
||
an unsupported `ExternalJson`/`None` kind) — a fire-and-forget bulk op still reports its outcome
|
||
rather than silently swallowing skips.
|
||
- **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked`
|
||
boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the
|
||
controller's list projection) rather than adding a push channel — and (as of #235) onto the
|
||
single-playout GET DTO (`PlayoutResponseModel.IsLocked`, set the same way in every action that
|
||
maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409,
|
||
refreshes to pick up the flag.
|
||
- **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background
|
||
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/v1/channels/{id:int}/playout/reset`
|
||
resolves the channel's playout by the immutable channel **`Id`** (`GetPlayoutIdByChannelId`), guards
|
||
on `IsPlayoutLocked` → 409, queues a `BuildPlayout` → `AcceptedResult`, and 404s when the channel has
|
||
no playout). It is keyed on `{id:int}`, **not** `{channelNumber}` — the single-item Channel admin
|
||
contract keys on `Id`, never the user-mutable `Number` (re-keyed in #197 Bundle C; see
|
||
`docs/decisions.md` 2026-07-12). The broadcast-side lookup (`GetPlayoutIdByChannelNumber`, used by
|
||
`HlsSessionWorker`) stays number-keyed — a separate contract. Reserve 200 for a synchronous durable result.
|
||
- **Handler-side atomic lock loss also maps to 409, via a typed error, not 422** (issue #316 review):
|
||
when the *handler itself* is the one that atomically acquires an `IEntityLocker` lock (not just a
|
||
controller pre-check) and loses the race, return `new LockedError(...)` (`ErsatzTV.Core/Errors/LockedError.cs`,
|
||
sibling of `NotFoundError`/`PreconditionFailedError`) from the handler — `ToErrorResult()` maps it to
|
||
409 automatically. Exemplar: `PrepareTroubleshootingPlaybackHandler` — `TroubleshootController` does a
|
||
cheap `IsTroubleshootingPlaybackLocked()` pre-check (advisory, §3a's check-then-act caveat applies),
|
||
but the handler's own `LockTroubleshootingPlayback()` is the atomic acquire; if *that* loses the race
|
||
it returns `LockedError`, so the 409 survives even when the pre-check passed a moment too early. Don't
|
||
let a handler-side lock loss fall through to the generic 422 `BaseError.New(...)`.
|
||
|
||
### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool`
|
||
|
||
When an endpoint *starts* a background operation guarded by an `IEntityLocker` lock, return an
|
||
**outcome enum from the handler** and map it in the controller — don't collapse distinct outcomes
|
||
into a lying `bool`/200. Exemplar (issue #232): `QueueLibraryScanByLibraryId` →
|
||
`QueueLibraryScanResult { Queued | NotFound | SyncDisabled | AlreadyScanning }`, mapped by
|
||
`LibrariesController.ScanLibrary` to **202** (`AcceptedResult`, queued), **404**
|
||
(`ApiResults.NotFoundProblem`), **422** (`UnprocessableEntityObjectResult` + `ProblemDetails`, a
|
||
domain precondition such as sync-disabled), and **409** (`ApiResults.ConflictProblem`, the lock is
|
||
already held = already scanning). Here the acquired lock **is** the running job, so
|
||
`LockLibrary(id) == false` means "already scanning" → 409 (a variant of §3a where the lock is the
|
||
operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 202/404/409/422 and
|
||
`typeof(ProblemDetails)` on the error ones. **Guard the lock→enqueue** with the
|
||
`EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws
|
||
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release.
|
||
|
||
A second exemplar (issue #235 slice B), where the lock lives on the **controller** rather than in a
|
||
handler: `POST /api/v1/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the
|
||
per-source collections lock (`entityLocker.LockPlexCollections()` etc.) — the lock IS the running
|
||
collections scan, so a `false` = 409 — then `WriteAsync`es `Synchronize{X}Collections(id, ForceScan:
|
||
true, deep)` to the scanner channel and returns **202**. `ScannerService` releases that lock in a
|
||
`finally` when it processes the message; the controller compensating-unlocks in a `catch` if the
|
||
enqueue throws. `POST /api/v1/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
|
||
deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`.
|
||
|
||
**Status counterpart for a lock-backed async op.** A queue-triggering endpoint whose "is it running?"
|
||
state lives in a lock/registry should expose a **GET status surface** the SPA can poll to reconcile its
|
||
optimistic pending flag, rather than relying on a client-side timeout. Two exemplars:
|
||
`GET /api/v1/libraries/scan-status` reads `IScannerProxyService.GetActiveScans()` (per-library, with
|
||
percent); `GET /api/v1/media-sources/collections-scan-status` (#271) reads
|
||
`IEntityLocker.Are{X}CollectionsLocked()` and returns one `{family}` entry per **family-global**
|
||
collections lock that's held (no id, no percent — the lock granularity dictates the DTO shape). Return
|
||
only the *active* entries (empty list = nothing running), mirroring the queue op's own lock.
|
||
|
||
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
|
||
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
|
||
|
||
**Handler-hardening checklist** when you touch (or add) a handler behind a new/changed endpoint:
|
||
- Missing FK existence checks that would otherwise 500 → convert to a `NotFoundError` (404) or a
|
||
validation `BaseError` (422).
|
||
- Dictionary-indexer lookups (`dict[key]`) that can throw `KeyNotFoundException` → guard or use
|
||
`TryGetValue`.
|
||
- Silent item filtering (e.g. `.Where(x => x.IsValid)` dropping bad rows without telling the
|
||
caller) → surface as 422 instead of silently returning a shorter list.
|
||
- Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination
|
||
pattern above.
|
||
- **Dereferencing a request `string` (e.g. `request.Name.Length`) is a latent 500** — request DTOs
|
||
carry no `#nullable` context (§2), so a `string Name` binds `null` from `name: null`/an omitted field
|
||
and there is no implicit `[Required]`; a raw `.Length`/`.Trim()` throws `NullReferenceException` → an
|
||
unhandled **500** (there is no global exception filter). Validate names null-safe: reuse the
|
||
`Validators.NotEmpty(x => x.Name).Bind(_ => x.NotLongerThan(50)(x => x.Name))` combinator
|
||
(`ErsatzTV.Application/Validators/StringValidation.cs`; both are null-safe via `Optional`), the same
|
||
pattern the group-create handlers already use — or at minimum guard `string.IsNullOrWhiteSpace(name)`
|
||
before any member access. Fixed across 10 create/replace handlers in issue #172 (was `if
|
||
(request.Name.Length > 50)`).
|
||
- **Known, deliberate exception**: deep FK ids nested inside item-list request bodies (e.g. a
|
||
schedule item's `CollectionId`) are **not** existence-checked at that depth — this is established
|
||
precedent from the schedules endpoints (see issue #172) and intentional to avoid N+1 validation
|
||
queries; don't "fix" this without discussing it first.
|
||
|
||
### 3c. A durable-save PUT that also triggers a background sync as a side effect
|
||
|
||
Some PUT-replace endpoints (issue #202: `PUT /api/v1/media-sources/{plex|jellyfin|emby}/{id}/libraries`)
|
||
have a synchronous durable write as their primary purpose — the response must reflect that write —
|
||
but Blazor's editor also fired off a background sync per newly-enabled library after the save. This
|
||
is a **different shape from §3b**: §3b is for an endpoint whose entire job *is* starting a
|
||
background operation (so the outcome enum drives the status code); here the enqueue is a fire-and-
|
||
forget side effect of an otherwise-ordinary write, and the response must still be **200 with the
|
||
durably-saved data**, not 202.
|
||
|
||
Pattern (see `PlexMediaSourcesController.ReplaceLibraryPreferences` / `EnqueuePostSaveSync`):
|
||
1. Dispatch the write command; on `Left`, return the error — nothing is enqueued.
|
||
2. **Reload** the saved rows through the same query the GET uses (per §7) — needed both for the
|
||
response and because ids can change as a result of the save (e.g. a disable-then-re-add).
|
||
3. Iterate the reloaded rows and, per row that needs a sync, `LockLibrary(id)` — **skip rows whose
|
||
lock is already held** (mirrors Blazor's `if (Locker.LockLibrary(id))` loop; no 409 for the save
|
||
itself, only a silent skip for that one row's sync).
|
||
4. Enqueue the background message(s) for that row; if a multi-message enqueue can throw partway
|
||
through, wrap it in `try/catch` and release the lock in the `catch` (compensating unlock) before
|
||
rethrowing — the standard `EnqueueWithTraktLock` one-lock-⇄-one-release discipline (§3b), just
|
||
invoked from inside a write endpoint instead of a dedicated "start background job" endpoint.
|
||
5. Return the reloaded data with `200 OK` — never 202 — since the durable save already happened;
|
||
the enqueue is best-effort and its failure (after a caught/compensated exception) surfaces as a
|
||
500 on this same request rather than silently dropping the sync.
|
||
|
||
This is server-side (not SPA-orchestrated) because the SPA has no access to the scanner channel,
|
||
and having the SPA fire a second request after the save would open a crash window between "saved"
|
||
and "synced". See `docs/decisions.md` 2026-07-11 (#202) for the fuller rationale and the specific
|
||
bug this pattern corrected (Blazor's Plex `Unlock` ordering released the library lock before a
|
||
dependent second message ran).
|
||
|
||
## 4. Artwork contract
|
||
|
||
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
|
||
`/artwork/thumbnails/...`, plus passthrough for `http://`/`https://` absolute URLs and for
|
||
Jellyfin/Emby proxy variants. This was established by PR #181 in
|
||
`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` (private `Artwork(...)`
|
||
helper, ~line 1330) — copy that helper's logic (or call a shared version of it) for any new
|
||
API surface that returns artwork paths. Comment in that file: *"Returns a rooted, directly-usable
|
||
artwork URL for the SPA's `<img src>`. Blazor pages rely on GetPosterUrl to prefix
|
||
`artwork/posters/`... but the SPA [needs it pre-rooted]."*
|
||
|
||
Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television`
|
||
mappers) for new API DTOs — those still return the old Blazor-convention relative paths. Map from
|
||
the domain/VM directly and root the path yourself, following the PR #181 pattern.
|
||
|
||
Channel **logos** live under a different route than posters/thumbnails: an uploaded logo roots to
|
||
`/iptv/logos/{file}` (served by `IptvController`). An **external logo URL is no longer stored as a URL** —
|
||
since #525, `PUT`/`POST /api/v1/channels…` downloads it, decode-validates it, and caches it at save
|
||
time, so `Artwork.Path` holds a content-hash name and the browse/guide DTOs emit an `/iptv/logos/…`
|
||
URL exactly as for an uploaded logo. Browse-surface DTOs (`ChannelResponseModel` list,
|
||
`ChannelGuideChannelResponseModel` guide) get this rooted `Logo` URL from the single
|
||
`Channels.Mapper.GetLogoUrl` helper (#464), which returns `null` when the channel has no logo so the
|
||
SPA falls back to its generated initials icon. The raw un-rooted `{path, contentType}` form is still
|
||
used only by the channel **editor** DTO (`ChannelDetailResponseModel.Logo`), which round-trips it back
|
||
on save.
|
||
|
||
**New logo-download rejections (#525).** `PUT /api/v1/channels/{id}`, the two channel-create
|
||
endpoints, and `POST /api/v1/artwork/uploads` now reject a logo that cannot be used. The failure is a
|
||
`BaseError`, so it surfaces as this API's standard **422 `ValidationProblemDetails`** (via
|
||
`ToErrorResult()`), **not** a 400 — a 400 here still means model-binding/validation-attribute failure.
|
||
Rejected cases: an external URL that is unreachable, times out (>10s), is oversized (>10 MiB), is not
|
||
an image, or is a decode bomb (over 50 MP total pixels or 600 frames); an upload gets the same
|
||
decode-budget check. The `detail` names the reason (e.g. *"Could not download logo from … : Connection
|
||
refused"*, *"Remote image … returned content type 'text/html'"*, *"Image cannot be used: … pixel
|
||
limit"*). Response **shapes are unchanged** — only the error set — so the OpenAPI models did not change.
|
||
(Verified by local live-E2E: good URL → cached `/iptv/logos/<hash>`; unreachable/non-image → 422.)
|
||
|
||
`GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name`
|
||
(#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its
|
||
user-editable name. The full geometry still requires `GET /api/v1/watermarks/{id}`.
|
||
|
||
### 4a. Artwork content type is sniffed, never client-supplied (issue #283)
|
||
|
||
The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content
|
||
type — doing so was a stored-XSS chain (upload `<script>` as `image/png`, serve it back as
|
||
`text/html`). The contract:
|
||
- **Upload**: `POST /api/v1/artwork/uploads` derives the content type from the actual bytes via
|
||
`ErsatzTV.Core/Images/ImageContentTypes.DetectContentType` (SkiaSharp header sniff — no full
|
||
decode), rejecting non-images 422. That helper (`Accepted` set + `IsAccepted`) is the **single
|
||
source of truth** for which image types are allowed — reuse it, don't re-list content types.
|
||
- **Serve**: the image routes derive the served `Content-Type` from the stored file; there is **no**
|
||
`?contentType=` query parameter. Never add one back — a client must not be able to choose the
|
||
`Content-Type` of an unauthenticated response.
|
||
- **Persisted `{path, contentType}` DTOs** (logo/watermark) run their content type through
|
||
`ArtworkContentTypeModel.Sanitized()` before storage, blanking anything outside the allow-list.
|
||
See `docs/decisions.md` 2026-07-12 (#283) for the fuller rationale.
|
||
|
||
## 5. OpenAPI regeneration — commit both generated artifacts
|
||
|
||
After any controller/DTO change:
|
||
1. `dotnet build ErsatzTV.sln` (normal build first). **Do not skip this.** `update-openapi.sh` runs
|
||
`dotnet-getdocument` against the *already-built* `ErsatzTV.dll`; an incremental
|
||
`-t:GenerateOpenApiDocuments` will **not** notice a stale assembly and will silently serialize the
|
||
old spec (e.g. dropping the `security`/`401` blocks the auth transformers add). Build first so the
|
||
assembly is current. (CI is immune — a fresh checkout has no `bin/` and always compiles.)
|
||
2. `./scripts/update-openapi.sh` — runs `dotnet build -t:GenerateOpenApiDocuments` from `ErsatzTV/`,
|
||
regenerating `ErsatzTV/wwwroot/openapi/v1.json` **and** `docs/endpoint-index.md`.
|
||
3. `cd web && npm run generate:api` — runs `scripts/generate-openapi-types.mjs`, regenerating
|
||
`web/src/api/generated/v1.d.ts`.
|
||
|
||
**Commit all three** — `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, and
|
||
`docs/endpoint-index.md`. The `.d.ts` file is what the SPA actually imports (`web/src/api/*.ts` files do
|
||
`import type { components } from './generated/v1'`). CI enforces sync in two places:
|
||
- `npm run check:api` (in the `test` job, always) = regenerate `.d.ts` from the committed `v1.json` +
|
||
`git diff --exit-code`.
|
||
- the **`api-docs` job** (ersatztv#303 H4/H5) — **blocking**, fires when a PR diff touches
|
||
`ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**`: it rebuilds `v1.json` / `v1.d.ts` /
|
||
`endpoint-index.md` from source and fails if any of the three is stale in the diff. This is the
|
||
mechanized half of the "docs-update in the same PR" rule for the API contract.
|
||
|
||
**Endpoint inventory additions (#69, auto-tuning PR1 backend)**: two `ChannelController` actions,
|
||
both requiring the standard credential (session-or-key, per §9 — no `[SkipApiAuthorization]`/
|
||
`[RequiresAuthentication]` override):
|
||
|
||
| Method | Path | Operation | Summary |
|
||
|---|---|---|---|
|
||
| POST | `/api/v1/channels/auto-tune/preview` | `PreviewAutoTuneChannels` | Preview auto-tuned channels |
|
||
| POST | `/api/v1/channels/auto-tune` | `CreateAutoTunedChannels` | Create auto-tuned channels |
|
||
|
||
**Endpoint inventory addition (#384, Auto-Tune DetailPanel backend)**: one read-only `ChannelController`
|
||
GET, standard credential (catalog-read tier — no `[RequiresAuthentication]`):
|
||
|
||
| Method | Path | Operation | Summary |
|
||
|---|---|---|---|
|
||
| GET | `/api/v1/channels/auto-tune/members` | `GetAutoTuneChannelMembers` | List a proposed auto-tune channel's distinct content-source members |
|
||
|
||
It takes `?axis=&value=&pageNum=&pageSize=` and reuses the existing `PagedLibraryBrowseItemsResponseModel`
|
||
/ `LibraryBrowseItemResponseModel` DTOs (no new schema). The handler runs the server-owned
|
||
`AutoTuneAxisMap.GenerateQuery(axis, value)` through `ISearchIndex.Search` (client never sends Lucene, per
|
||
the #69 PR1 decision), then rolls matching leaf items up to their distinct content sources — parent shows for
|
||
the TV axes (`ItemCount` = query-matching episodes, **not** the show's total), movies for the movie-genre
|
||
axis. Paging is clamped (`pageNum` floored at 0; `pageSize` defaults to 100, clamped 1–200) per the §1 Logs
|
||
precedent. See `docs/decisions.md` 2026-07-17 (#384) for the search-index-vs-EF-enumeration rationale.
|
||
|
||
**DTO expansion (#385, Auto-Tune DetailPanel per-channel overrides)**: no new endpoint — the existing
|
||
`POST /api/v1/channels/auto-tune` request (`AutoTunedChannelRequest`) gained three **optional** per-channel
|
||
fields: `templateId` (overrides the batch template), `advanced` (reuses the manual Channel Builder's
|
||
`CreateChannelFromLineupAdvancedOptionsRequest` verbatim — same 24-field override set + `ToCommand()`), and
|
||
`logo` (an uploaded `{path, contentType}` image, `Sanitized()` at the request boundary per §4a). Omitting each
|
||
preserves PR1 behavior exactly (batch template, axis-derived playback order, on-the-fly fallback logo). This is
|
||
a second caller of the `from-lineup` advanced-options wire contract — do **not** mint a parallel DTO. Regenerated
|
||
the OpenAPI trio (v1.json/v1.d.ts/endpoint-index) even though only schemas changed.
|
||
|
||
**DTO expansion (#425, per-source rotation weights + query corrections)**: again no new endpoint — the same
|
||
`POST /api/v1/channels/auto-tune` request gained one more **optional** per-channel field, `sources:
|
||
[{sourceId, weight, excluded}]` (`AutoTuneSourceWeightRequest`; `sourceId` is a show/movie id from the members
|
||
list). Omitting it, or sending only fair-share weights with nothing excluded/added, keeps the single-SmartCollection
|
||
channel; otherwise the channel is built as a system-owned MultiCollection of per-source SmartCollections with
|
||
`WeightedShuffle` (see `docs/decisions.md` 2026-07-18 #425 for the materialization/remainder semantics). Only the
|
||
request schema changed, so the OpenAPI trio was regenerated (v1.json/v1.d.ts/endpoint-index).
|
||
|
||
**Endpoint inventory addition (#176, visual rule builder backend)**: one read-only `SearchController`
|
||
GET, standard credential (catalog-read tier — no `[RequiresAuthentication]`):
|
||
|
||
| Method | Path | Operation | Summary |
|
||
|---|---|---|---|
|
||
| GET | `/api/v1/search/fields` | `GetSearchFields` | List the filterable fields for the visual rule builder |
|
||
|
||
Returns the curated `SearchFieldCatalog` (name, friendly label, type, UI group, and allowed values for
|
||
enum fields) as `List<SearchFieldResponseModel>`. Drives the SmartCollection rule builder and is
|
||
introspectable by MCP; no query parameters.
|
||
|
||
**Endpoint inventory addition (#434, facet-value typeahead)**: one read-only `SearchController` GET,
|
||
standard credential (catalog-read tier — no `[RequiresAuthentication]`):
|
||
|
||
| Method | Path | Operation | Summary |
|
||
|---|---|---|---|
|
||
| GET | `/api/v1/search/fields/{name}/values` | `GetSearchFieldValues` | List distinct term values for a text search field |
|
||
|
||
Query params: `q` (optional prefix filter, case-insensitive, default empty) and `limit` (optional,
|
||
clamped `1..50`, default 50). `{name}` is allow-listed to `SearchFieldCatalog` fields with
|
||
`type: "text"` AND a distinct-value source in the database — an unknown field, a non-text field (e.g.
|
||
an enum), or a text field without a source (`title`, `show_title`, `album_artist`) 404s rather than
|
||
returning an empty list, since enum fields already ship their values inline on
|
||
`GET /api/v1/search/fields` and never need this endpoint. Returns `SearchFieldValuesResponseModel`
|
||
(`{ values: string[] }`), sourced from a per-field distinct-values DB query (`IDbContextFactory<TvContext>`),
|
||
not the Lucene term dictionary — analyzed text fields store lowercased word tokens, not whole values.
|
||
No server-side caching. Powers the visual rule builder's value-input combobox for text fields; see
|
||
`docs/decisions.md` 2026-07-23 (#434) and `spa-conventions.md` §12.
|
||
|
||
**Param + DTO expansion (#293, cap `search/all-items`)**: no new endpoint — `GET /api/v1/search/all-items`
|
||
gained two **optional** query params (`pageSize` default 500, clamped 1–1000 via the §1 Logs `Math.Clamp`
|
||
precedent; `pageNum` 0-based, clamped `0..2_000_000` so `pageNum * pageSize` can't overflow `int` to a 500)
|
||
so a broad query can't materialize the whole index in one response, and one
|
||
**additive** response field, `Totals` (`SearchResultAllItemsTotalsResponseModel`, ten per-kind `…Count`
|
||
ints), so a client can page to completeness. The clamp is per media kind, so one response is bounded to
|
||
≤ 10 × `pageSize` ids. The SPA add-all flow (`getAllSearchItemIds` in `web/src/api/search.ts`) pages until
|
||
each kind has collected its `Totals` count. Changing the no-param default from "everything" to one page is an
|
||
intentional, security-motivated behavior change (only in-repo consumer is the SPA, updated in the same PR;
|
||
external callers read `Totals` and page). Regenerated the OpenAPI trio. See `docs/decisions.md` 2026-07-18
|
||
(#293).
|
||
|
||
**Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while
|
||
the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to
|
||
`Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching
|
||
the wire. The SPA's old manual `WithDayNames<T>` override in `web/src/api/playouts.ts` has been removed
|
||
— the generated `daysOfWeek: DayOfWeek[]` type is now correct. Do **not** re-introduce an override for
|
||
new `DayOfWeek` DTOs; if you add a *new* BCL/`System` enum that serializes as a name string, add its
|
||
`typeof(...)` to that same hand-list rather than patching the SPA.
|
||
|
||
### 5a. Runtime JSON casing vs the generated spec (the `ffmpegProfileId` wart)
|
||
|
||
Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `Startup.cs`), using
|
||
`ErsatzTV/Serialization/CustomContractResolver.cs` → `CustomNamingStrategy` (camelCase **plus** a
|
||
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
|
||
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile` →
|
||
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
|
||
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
|
||
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
|
||
|
||
Fix (do not remove): `ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs` is an OpenAPI
|
||
**schema transformer** registered on all three documents (`options.AddSchemaTransformer(...)` in
|
||
`Startup.cs`). For each object schema it resolves the CLR type's Newtonsoft `JsonObjectContract`
|
||
through the *same* `CustomContractResolver` the runtime uses and renames `schema.Properties` (and
|
||
`schema.Required`) keys to the exact names Newtonsoft would emit. This mirrors the wire format **by
|
||
construction**, so future naming-strategy special cases or `[JsonProperty]` renames can't drift.
|
||
|
||
Guard: `ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs` serializes fully-populated
|
||
DTOs (ChannelViewModel, FFmpegSettingsResponseModel, WatermarkViewModel,
|
||
MediaItemInfoResponseModel) through the runtime Newtonsoft settings and asserts the emitted top-level
|
||
keys equal the corresponding `v1.json` schema's property set. It fails if spec generation ever drifts
|
||
from the MVC serializer again. Note: only exact-match `FFmpegProfileId` gets the special case —
|
||
`DefaultFFmpegProfileId` stays `defaultFFmpegProfileId` under both serializers, and non-acronym or
|
||
single-leading-cap names (`fFmpegPath`, `fFprobePath`, `zIndex`, `rFrameRate`) already agree.
|
||
|
||
## 6. Tests
|
||
|
||
- **Controller tests**: `ErsatzTV.Tests/Controllers/<Domain>ControllerTests.cs`. NUnit + Shouldly +
|
||
NSubstitute (mock `IMediator`). Exemplar: `ErsatzTV.Tests/Controllers/TemplateControllerTests.cs`
|
||
— asserts every route via a `ShouldHaveActionRoute(actionName, verb, path)` helper (route-table
|
||
regression net), then per-action tests asserting the DTO shape returned and the exact
|
||
`mediator.Received(1).Send(Arg.Is<Command>(...))` call. `PlayoutControllerTests.cs` is the same
|
||
pattern for a larger, mixed-verb controller — use it as the template for a new controller with
|
||
many actions.
|
||
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): reflects over the
|
||
`ErsatzTV.Controllers.Api` namespace to find every concrete, `[ApiController]`-marked
|
||
controller class — no manual registry to maintain; a new controller is covered
|
||
automatically. (It intentionally does **not** filter on `ControllerBase`: several API
|
||
controllers — including the lone exempt `ScannerController` — do not derive from it, and a
|
||
`ControllerBase` filter would silently drop them.) The test walks every mutating (POST/PUT/PATCH/DELETE) action on each scanned
|
||
controller and asserts it's covered by the global `ApiKeyAuthorizationFilter` (or has an
|
||
explicit `[SkipApiKeyAuthorizationAttribute]` exemption; only `ScannerController` is exempt
|
||
today). A minimum-count guard asserts the scan found a sane number of controllers, so a
|
||
namespace rename can't silently make the scan match nothing and give this test a false pass.
|
||
It also asserts the **sensitive-read tier** (`Troubleshoot`/`Logs`/`Settings`/`Maintenance`)
|
||
carries `[RequiresApiKey]` and that `ScannerController` carries `[LocalhostOnly]` — reflectively,
|
||
so the tier can't silently drop a gate. `ApiKeyAuthorizationFilterTests.cs` unit-tests the filter
|
||
itself: writes are always fail-closed, reads are gated when `Api:RequireKeyForReads` (default true)
|
||
or `[RequiresApiKey]`, `OPTIONS` preflight and non-`/api` paths are exempt.
|
||
- **Handler tests** (business logic behind the controller) use the shared in-memory SQLite fixture:
|
||
`ErsatzTV.Tests/Support/InMemoryTvContext.cs`. Pattern: `SqliteConnection("Data Source=:memory:;Foreign
|
||
Keys=False")` kept open for the fixture's lifetime, `EnsureCreatedAsync()` (not full migration
|
||
replay), then `PRAGMA foreign_keys=OFF` so partial object graphs can be seeded without satisfying
|
||
every FK. Exemplar: `ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs`
|
||
(`_db = await InMemoryTvContext.CreateAsync();` in `[SetUp]`, `_db.CreateContext()` per test body,
|
||
`_db.Factory` where an `IDbContextFactory<TvContext>` is needed by the handler under test).
|
||
|
||
## 7. PUT-replace list endpoints
|
||
|
||
For "replace the whole list" endpoints (PUT semantics over a collection, e.g. schedule/template
|
||
items), index items from **array order** in the request body rather than trusting a client-supplied
|
||
index/order field. Exemplar: `ReplaceScheduleItemsRequest.ToCommand(scheduleId)` —
|
||
`Items.Select((item, index) => item.ToReplaceCommand(index))`.
|
||
|
||
**Project the write-path response through the same include chain the GET uses — never off the
|
||
freshly-built graph.** After `SaveChanges`, a command's entities carry only the foreign-key ids you set
|
||
(e.g. `ProgramScheduleItemWatermark.WatermarkId`); their reference navs are null, and any mapper that
|
||
dereferences one unguarded throws an NRE that surfaces as a 500. Reload with the read-side includes
|
||
before mapping. Exemplars: `ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler` reload
|
||
via `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` (the one include chain shared with
|
||
`GetProgramScheduleItemsHandler`). Also beware LanguageExt `Map` is **lazy** — returning
|
||
`items.Map(ProjectToViewModel)` defers the projection, so a test that only checks `.IsRight` won't catch
|
||
the NRE; the controller's `.ToList()`/serialization does (regression: `ScheduleItemWriteProjectionTests`).
|
||
GET handlers that feed an ordered list must also `.OrderBy(i => i.Index)` — id order is not index order.
|
||
|
||
## 7a. PUT-replace concurrency (ETag / If-Match / 412)
|
||
|
||
The replace-all aggregate PUTs (blocks, templates, schedules items, playlists, collections, playouts,
|
||
etc.) carry an **optimistic-concurrency contract** so a stale second tab can't silently overwrite a
|
||
fresher edit (issue #253). The Block endpoints are the reference implementation; **PR2 fanned the same
|
||
recipe onto Template, DecoTemplate, Playlist, and schedule-items** (`PUT /api/v1/templates/{id}`,
|
||
`/api/v1/deco-templates/{id}`, `/api/v1/playlists/{id}`, `/api/v1/schedules/{id}/items`); PR3 covers the
|
||
Diff/Scalar aggregates (Collection, Playout ×2, MultiCollection, RerunCollection) and PR4 is the Phase-2
|
||
428 flip.
|
||
|
||
Each replace PUT keeps its **own** existing 200 body shape (Template/DecoTemplate return a
|
||
`…WithItemsResponseModel`, Block likewise; Playlist and schedule-items return the item array) and adds the
|
||
ETag as a **header only**. As of #288 this holds for schedules and collections too: `ScheduleController`
|
||
and `CollectionController` now project through dedicated `ProgramScheduleResponseModel` /
|
||
`MediaCollectionResponseModel` DTOs, so `Version` is **header-only** on those bodies — the earlier
|
||
carve-out ("`ProgramScheduleViewModel` is returned directly, so `version` leaks into the GET body") no
|
||
longer applies. Verified against the SPA: neither editor reads body `version`; both source `If-Match`
|
||
from the ETag response header (`client.ts` `requestWithMeta`). Every replace PUT's *sibling config writers* bump `Version` too
|
||
(Playlist: the five `Add*ToPlaylist` handlers; schedule: `AddProgramScheduleItem` / `DeleteProgramScheduleItem`
|
||
/ `UpdateProgramSchedule`; Template/DecoTemplate have none).
|
||
|
||
**Token.** Each versioned root implements `IVersionedAggregate` (`int Version`, EF-mapped with
|
||
`.IsConcurrencyToken()` in its `IEntityTypeConfiguration`). A single dual-provider migration
|
||
(`AddAggregateVersions`) adds the column (`nullable: false, defaultValue: 0`). Do **not** overload the
|
||
existing `DateUpdated` — a plain `int` is portable across SQLite/MySQL and decoupled from UI cosmetics.
|
||
|
||
**Transport.** The aggregate's GET (the one the editor loads from — e.g. `GET /api/v1/blocks/{id}/items`)
|
||
emits a strong `ETag: "3"` of `Version`; the PUT sends it back as `If-Match: "3"`. Mismatch → **412
|
||
Precondition Failed** (distinct from the §3a **409** "build in progress" lock guard). A successful PUT
|
||
returns the **new** ETag (post-increment) so a same-tab second save doesn't 412 against its own write.
|
||
`If-Match: *` and (Phase 1) a missing header force-write. Per **RFC 7232 §3.1** (issue #265): a
|
||
syntactically-valid entity-tag that does not strong-match → **412**, not 400 — this includes a weak tag
|
||
(`W/"3"` never strong-matches), an empty tag (`""`), and a strong tag whose opaque text isn't the exact
|
||
canonical decimal we emit (`"03"`, `"3.0"`, an out-of-range value). Only a genuine **grammar** violation
|
||
(an unquoted value, SP inside the opaque-tag, an unterminated quote) → **400**. `If-Match` is also a
|
||
**list** (`"3", "5"`): any strong member that matches proceeds; weak/non-canonical members drop out. The
|
||
parser therefore yields a *set* of candidate versions, and a `Version`-kind with an **empty** set is a
|
||
guaranteed 412 (a valid-but-unmatchable tag). Parse/emit with `ErsatzTV.Extensions.ConcurrencyHeaders`
|
||
(`ParseIfMatch` → `IfMatchCondition.ExpectedVersions : Option<Seq<int>>`; `None` = force-write,
|
||
`Some(set)` = strong-match against the set, `SetETag`). The items GET returns *children*, so the controller reads `root.Version` separately for the
|
||
header (here `BlockViewModel` carries `Version`, projected but **not** echoed in the response body —
|
||
header-only).
|
||
|
||
**Handler recipe (the error-prone part).** Introduce the concurrency check as a **standalone `Either`
|
||
AFTER** the validation pipeline, never via `Apply` — `LanguageExtensions.Apply`/`ToEither` `Join()` a
|
||
`Seq<BaseError>` down to a base `BaseError`, which would flatten `PreconditionFailedError` to a 422. The
|
||
reference shape (`ReplaceBlockItemsHandler`):
|
||
|
||
```csharp
|
||
Either<BaseError, Block> validated = LanguageExtensions.ToEither(validation) // explicit: the native
|
||
.Bind(block => block.CheckVersion(request.ExpectedVersions)); // Validation.ToEither() shadows ours
|
||
return await validated.Match(
|
||
Right: block => Persist(dbContext, request, block, cancellationToken),
|
||
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
||
```
|
||
|
||
In `Persist`, bump **unconditionally** before saving — `root.Version++` — because EF emits the root
|
||
UPDATE only when a scalar actually differs, so a same-value/no-op PUT-back would otherwise neither fire
|
||
the token nor rotate other clients' ETags. Then save through
|
||
`dbContext.SaveChangesWithConcurrencyGuard(ct)` (maps `DbUpdateConcurrencyException` → 412), which is the
|
||
backstop that closes the load→save TOCTOU the pre-check can't. `CheckVersion` (pure, on
|
||
`IVersionedAggregate`) and `SaveChangesWithConcurrencyGuard` live in `ErsatzTV.Core` /
|
||
`ErsatzTV.Application` respectively.
|
||
|
||
Add `[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]` and `…412…` to the
|
||
PUT action. **Config-only boundary**: every mutating handler of an aggregate's *editor-visible config
|
||
state* bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers, which add
|
||
`.SetProperty(x => x.Version, x => x.Version + 1)`); regenerated build output (playout items/history) is
|
||
outside the token — its handlers neither bump nor are guarded. Test the reference with: stale-If-Match →
|
||
412 (no mutation), matching/absent → success + bump, no-op save still bumps, and a two-context racing
|
||
save → 412 (prove it non-vacuous by dropping `.IsConcurrencyToken()` and watching the race test fail).
|
||
Phase 2 (a later PR) flips a missing `If-Match` from force-write to **428 Precondition Required** once
|
||
every editor echoes and one release soaks.
|
||
|
||
**Fan-out status.** Block (#2) is the reference. **PR2** wired the RR + Reconcile aggregates
|
||
(Template #3, DecoTemplate #4, Playlist #5, schedule-items #1). **PR3** wired the Diff + Scalar aggregates:
|
||
Collection custom-order #6, Playout alternate-schedules #7 and templates #8 (both share `Playout.Version`;
|
||
their `catch(Exception)`→422 handlers were restructured so the guard's `PreconditionFailedError` Left
|
||
returns before the catch — §9/H1), MultiCollection #9, and RerunCollection #10. Two M2 gate notes for the
|
||
`SaveChangesAsync() > 0` handlers: the RerunCollection/Collection-custom-order refresh now runs on any
|
||
successful save (the bump makes the gate always-true); MultiCollection keeps its "name-only change → no
|
||
playout rebuild" optimization by bumping on the **first** (name) save so the **second** (items) save's
|
||
`> 0` still means "items changed". The one bulk writer in scope, `UpdateDefaultDecoHandler`, bumps via
|
||
`.SetProperty(x => x.Version, x => x.Version + 1)` (bulk `ExecuteUpdate` can't throw the concurrency
|
||
exception, so it needs no guard).
|
||
|
||
**Non-If-Match root writers → force-write, no 500 (#269).** Activating `Version` as an `IsConcurrencyToken`
|
||
makes EF append `WHERE Version=@orig` to *every* UPDATE **and DELETE** of the root — so a plain
|
||
`SaveChangesAsync` writer that is *not* part of the If-Match contract throws an unhandled
|
||
`DbUpdateConcurrencyException`→**500** when a replace-all editor bumps the row in its load→save window. Every
|
||
such writer now saves through `SaveChangesForcingVersion(ct)` (Phase-1 force-write: rebase onto the stored
|
||
token — original = stored, current = stored + the pending delta, so a `Version`-bumper's rotation still
|
||
advances the ETag *past* the concurrent writer's value instead of silently adopting it — and retry; rethrow
|
||
only if the row was genuinely deleted out from under it). **The exposure filter is "any
|
||
handler that leaves a versioned root `Modified` or `Deleted` on plain `SaveChangesAsync`", NOT just
|
||
`Version`-bumpers + deletes** — `ErasePlayoutHistoryHandler` modifies Playout scalars *without* bumping and
|
||
is exposed too. Covered (18 writers): the **9 aggregate delete handlers** (a delete has no ETag to rotate, so
|
||
it needs only the force-write, not a bump); the **item add/remove bumpers**
|
||
(`AddProgramScheduleItem`/`DeleteProgramScheduleItem`, `Add{Items,Movie,Show,Season,Episode}ToPlaylist`);
|
||
`UpdateProgramScheduleHandler`; `ErasePlayoutHistoryHandler` (root-scalar erase inside an explicit
|
||
transaction); and, from PR3, the Playout settings/`ScheduleFile`/on-demand-checkpoint writers and
|
||
`UpdateCollectionHandler`. (The If-Match replace handlers keep the 412 `SaveChangesWithConcurrencyGuard`;
|
||
`UpdateDefaultDeco`'s bulk `ExecuteUpdate` can't throw, so it needs neither.)
|
||
|
||
**Two deliberate boundaries.** (1) The **background build/time-shift** Playout-scalar writers
|
||
(`BuildPlayoutHandler` via `PlayoutBuilder`, `PlayoutTimeShifter`) are token-guarded too but stay on plain
|
||
save on purpose: they never surface a request-path 500 (`BuildPlayout` catches → a build-failure `BaseError`;
|
||
`PlayoutTimeShifter` runs only via the background worker), and force-writing would persist output built from
|
||
*stale* config — the concurrent config bump already enqueues a rebuild, so failing and rebuilding with fresh
|
||
config is correct. (2) **Item-add force-write can leave a duplicate/gap `Index`** (accepted
|
||
Phase-1 effect): the index is computed from the handler's stale child list, so a concurrent replace-all that
|
||
grew the list makes the add land at a colliding index (no unique constraint). Non-corrupting, self-correcting
|
||
on next edit, strictly better than the pre-#269 500; a reload-and-recompute-on-conflict refinement is a #197
|
||
candidate.
|
||
|
||
**Cross-editor ETag rotation — completed (#269).** The remaining non-bumping config siblings now rotate the
|
||
aggregate ETag so a concurrent editor of the *same root* invalidates on its next save: the Collection
|
||
`Add*ToCollection` family (all 11 handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`,
|
||
and `UpdateCollectionHandler` (name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three
|
||
`ScheduleFile` writers — which already force-wrote — now also bump. All rotate via `SaveChangesForcingVersion`
|
||
(they take no `If-Match`, so a concurrent bump force-writes, never 412/500). **Correction:** the
|
||
`Add*ToCollection` family is *not* repository-mediated — each handler loads the `Collection` into its own
|
||
`dbContext` and writes directly (`IMediaCollectionRepository` is read-only), so the rotation bump is purely an
|
||
API-layer concern and the scanner's separate membership-write path is unaffected (a background scan does not
|
||
rotate the editor ETag). **No-op idempotence (the trap):** these handlers gate their reindex/`BuildPlayout`
|
||
fan-out on `SaveChanges() > 0`; an *unconditional* bump makes that gate always-true, so an idempotent re-add /
|
||
same-value re-submit would fire spurious rebuilds. Each therefore short-circuits a genuine no-op **before** the
|
||
bump — the Add handlers by an explicit membership check (also fixing the latent duplicate-`CollectionItem`
|
||
insert on a *sequential* re-add), the scalar writers by `ChangeTracker.HasChanges()` — so a no-op neither
|
||
bumps nor rebuilds. This is an invalidation-completeness refinement; the primary endpoints' own bump+guard
|
||
already covered the two-tab lost-update the contract targets.
|
||
|
||
**Idempotent insert under concurrency (#308).** The membership pre-check is not atomic with the insert, so
|
||
two *concurrent* adds of the same item both observe it absent, both stage the `CollectionItem` composite key,
|
||
and the loser's `SaveChangesForcingVersion` throws a unique/PK-violation `DbUpdateException` (SQLite error 19 /
|
||
MySQL 1062) it does not catch → a 500. The `Add*ToCollection` family therefore saves through
|
||
**`ConcurrencyExtensions.TrySaveChangesForcingVersion`** (a `bool`-returning sibling of `SaveChangesForcingVersion`)
|
||
which catches *only* that classified violation and returns `false`. The single-item handlers treat `false` as an
|
||
idempotent **no-op** (the racing winner already inserted the row, rotated the ETag, and fanned out the rebuild);
|
||
the bulk `AddItemsToCollection` handler instead **retries** on a fresh context against recomputed membership so
|
||
the non-colliding items in the batch are not dropped (bounded loop; the common no-collision path runs once).
|
||
The provider-specific classifier is wired the same way as the other provider statics on `TvContext` — a settable
|
||
`TvContext.IsUniqueConstraintViolation` delegate pointed at `SqliteErrorClassifier` / `MySqlErrorClassifier`
|
||
(`ErsatzTV.Infrastructure.Sqlite/MySql.Data`) from `Startup.cs`, defaulting to a conservative "no" so an unwired
|
||
provider never silently swallows a save failure. `Add*ToPlaylist` is **not** affected: `PlaylistItem` has its own
|
||
identity PK and no unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item
|
||
more than once, so there is no constraint to violate.
|
||
|
||
## 7b. Post-commit side effects run on `CancellationToken.None`
|
||
|
||
Once a command handler's `await dbContext.SaveChangesAsync(cancellationToken)` (or repository upsert)
|
||
has **committed**, everything that runs afterwards to complete that mutation's side effect —
|
||
`channel.WriteAsync(new BuildPlayout(...))` / other worker-channel enqueues, `mediator.Publish(...)`,
|
||
`ISearchIndex`/reindex enqueues, a cache `Refresh(...)`, and any post-commit **lookup that gates one
|
||
of those enqueues** — must be passed **`CancellationToken.None`**, not the request `cancellationToken`.
|
||
|
||
Rationale (audit #22, issues #251 → #254): the request token is cancelled when the HTTP client
|
||
disconnects. If it's threaded into a post-commit enqueue, a late disconnect turns an *already-durable*
|
||
commit into a thrown request **and drops the side effect** (e.g. the rebuild is never queued → the
|
||
persisted change silently never takes visible effect). The commit is the point of no return: past it,
|
||
the compensating side effect must not be half-abortable. Exemplar idiom:
|
||
`ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs` (the affected-playout queries **and** the
|
||
`WriteAsync(BuildPlayout..., CancellationToken.None)` enqueue all use `None`).
|
||
|
||
Two boundaries:
|
||
- **Response projection is NOT a side effect.** The post-commit reload that builds the *returned* view
|
||
model (§7 above) legitimately keeps the request `cancellationToken` — if the client disconnected, we
|
||
don't need to compute a response nobody will read, and the durable work (commit + `None`-enqueue) has
|
||
already happened. Only the *side effect* chain gets `None`.
|
||
- **Background-job handlers keep their token.** `BuildPlayoutHandler` and other handlers invoked by the
|
||
worker (not by an HTTP request) receive the *worker's* shutdown token, not a client-disconnect token —
|
||
their downstream enqueues correctly stay on that token so a shutdown stops enqueuing more work.
|
||
|
||
Pre-commit reads/validation and the `SaveChangesAsync` call itself keep the request token (cancelling
|
||
*before* the commit safely aborts with nothing persisted). A handler that passes **no** token to a
|
||
post-commit `WriteAsync()` is already behaviorally correct (`default` == `CancellationToken.None`);
|
||
making it explicit is optional cleanup, not required. Config handlers that commit via several sequential
|
||
`IConfigElementRepository.Upsert` calls are a distinct partial-commit-under-cancellation case not covered
|
||
by this rule (tracked separately).
|
||
|
||
## 7c. Stable child identity in replace lists (schedule items)
|
||
|
||
§7 indexes replace-list children by **array order**, which is the reconcile *key* for most replace PUTs.
|
||
That is correct only when a child row is pure config: reordering merely re-numbers otherwise-interchangeable
|
||
rows. **Schedule items are the exception** (issue #259): a schedule item anchors persisted runtime state —
|
||
`PlayoutScheduleItemFillGroupIndex` (fill-group / shuffle enumerator progression) FKs the item row with
|
||
`OnDelete(Cascade)`. Reconciling those by position makes a **moved** item inherit the state of whatever item
|
||
previously occupied its new slot. So `PUT /api/v1/schedules/{id}/items` carries a stable child identity:
|
||
|
||
- `ScheduleItemRequest.Id` (`int?`) round-trips each existing item's server id (as returned by the items GET).
|
||
**null / absent / 0 ⇒ a new item** (the controller normalizes `0`→null so the handler contract is
|
||
two-state). Never fabricate an id.
|
||
- When **any** request item carries an id, `ReplaceProgramScheduleItemsHandler` reconciles **by id**: matched
|
||
same-subtype rows are updated in place (keeping the id, so the fill-group index never cascades and follows
|
||
the logical item across reorders/inserts); a matched row whose TPT subtype changed is delete+insert (state
|
||
resets, a **new id is returned** — clients must re-sync from the PUT response); unreferenced existing rows are
|
||
deleted; id-less request items are inserted. `Index` is still array-position (ordering is a separate axis
|
||
from identity).
|
||
- **A fully id-less payload falls back to the verbatim positional reconcile** (legacy clients). This preserves
|
||
today's misattribution-on-reorder for such payloads — it is temporary and retires together with the §7a
|
||
Phase-2 `If-Match`→428 flip.
|
||
- **Guards run inside the handler, after the §7a `CheckVersion`** (so a client that is both version-stale and
|
||
id-stale gets **412**, the reload signal, not 422): a duplicate id in one payload → **422**; an id not
|
||
belonging to this schedule → **422** (under Phase-1 force-write a stale id is a live lost-update signal, not
|
||
a new item — reject rather than silently duplicate). Both persist nothing.
|
||
|
||
**Deliberate asymmetry**: the other positional replace handlers (blocks #2, templates #3, deco-templates #4,
|
||
playlists #5) do **not** carry a child id — their children are stateless config rows where positional churn is
|
||
unobservable (#3/#4 don't even emit a child id on GET). Child ids are added only where a child row anchors
|
||
server-side state; positional replace stays the default. A per-endpoint child-id contract can be retrofitted
|
||
later without breaking anything (the field stays optional).
|
||
|
||
## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows)
|
||
|
||
`GET /api/v1/blocks` and `GET /api/v1/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
|
||
`ErsatzTV.Application/Scheduling/Queries/`) synthesize a fake **negative-id "(none)" group row**
|
||
for items that have no group, so the SPA can render an "ungrouped" bucket
|
||
(`Id = unusedGroup.Id * -1`, `Name = "(none)"`). See issue #172. If you add a similar "ungrouped"
|
||
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
|
||
reliable real-entity id for those synthetic rows.
|
||
|
||
**Channel graphics (issue #74)**: `ChannelDetailResponseModel`/`UpdateChannelRequest` carry
|
||
`graphicsElementIds` (the channel's attached `GraphicsElement` ids, reconciled add/remove on PUT via
|
||
`Channel.ChannelGraphicsElements`), and `GraphicsElementResponseModel` exposes a server-derived
|
||
`builtIn` (`Path.GetFileName(element.Path) == GraphicsElementDefaults.OnNowNextFileName`) — never
|
||
client-settable.
|
||
|
||
## 9. Authentication — session-or-key posture (fail-closed)
|
||
|
||
The whole `/api` surface is gated by the global `ApiAuthorizationFilter` (renamed from
|
||
`ApiKeyAuthorizationFilter` in #295). A request that requires authentication is accepted on **either**
|
||
credential:
|
||
|
||
1. a matching machine **`X-Api-Key`** header (MCP / external clients — issue #197 Bundle A), or
|
||
2. an **authenticated session** principal (browser cookie `ctv-session`, from local login or OIDC — #295).
|
||
|
||
**Which endpoints require authentication is unchanged** and still decided by the single shared predicate
|
||
`ApiAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` (also used by
|
||
OpenAPI generation, so the spec can't drift). When you add an endpoint:
|
||
|
||
- **Do nothing** for the common case. Writes (POST/PUT/PATCH/DELETE) always require a credential
|
||
(fail-closed — there is no "open" mode). Reads (GET/HEAD) require one when `Api:RequireKeyForReads` is
|
||
enabled, the **default** (`true`). `OPTIONS` preflight is exempt.
|
||
- The effective machine key comes from `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`):
|
||
`Api:WriteKey` if configured, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`,
|
||
`0600`), else a freshly generated 256-bit key. It is never empty.
|
||
- **CSRF (session credential only).** The machine key is CSRF-immune (a browser can't set a custom header
|
||
cross-origin without a credentialed CORS grant, which is never issued). A cookie session is not: a
|
||
**session-authenticated mutation** must additionally carry the `X-CSRF` header (`ApiAuthorizationFilter.CsrfHeaderName`)
|
||
or it is rejected **403** — presence is the whole check (a custom header forces a CORS preflight a
|
||
cross-site page can't satisfy; reinforced by `SameSite=Lax` + CORS without `AllowCredentials`). Key-authed
|
||
requests are exempt. When you add a SPA mutation, send `X-CSRF: 1` (the SPA client does this centrally).
|
||
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresAuthentication]`
|
||
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
|
||
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
|
||
`Maintenance`, plus local-library detail (`GET /api/v1/libraries/local/{id}`), whose response contains
|
||
server filesystem paths. The ordinary local-library list remains in the catalog-read opt-out tier.
|
||
`ApiControllerSecurityTests` asserts these boundaries reflectively.
|
||
- **Internal loopback callbacks** (the scanner's `/api/v1/scan/*`) and the **`/api/v1/auth/*` surface itself** use
|
||
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
|
||
auth surface must be reachable before a caller is authenticated, and its one sensitive action
|
||
(`POST /api/v1/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
|
||
**only** auth-exempt controllers.
|
||
- **The `/api/v1/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
|
||
OpenAPI doc, whose audience is machine clients): `GET config` (what auth options exist + `setupRequired`),
|
||
`GET session`, `POST setup` (first-run claim), `POST login`, `POST logout`, `POST password`, `GET machine-key`
|
||
(returns the server machine key to an authenticated session — the SPA's machine-key-management screen). The browser-nav
|
||
OIDC challenge is `GET /auth/oidc/login` (outside `/api`). `login`/`setup`/`password` carry a per-IP rate
|
||
limit (`[EnableRateLimiting("auth")]`, 10 / 5 min). The local admin is a single credential in `ConfigElement`
|
||
rows (`AuthLocalAdminUsername`/`AuthLocalAdminPasswordHash` (PBKDF2) / `AuthSecurityStamp`) — **no DB
|
||
migration**; a password change rotates the stamp, revoking sessions via the cookie `OnValidatePrincipal`.
|
||
Recovery/bootstrap without the browser: set `Auth:LocalAdmin:Password` (+ optional `…:Username`, default
|
||
`admin`) and restart (`LocalAdminSeedService` reseeds + rotates the stamp). While that env is set the
|
||
**browser setup-claim is disabled** (409) — the env seed owns the credential, which also removes the
|
||
startup setup-vs-seed race. Logout rotates the security stamp for a local session (ends it server-side,
|
||
"log out everywhere"), gated on an authenticated session.
|
||
- **CORS** is opt-in: no cross-origin access by default (the SPA is same-origin from `/app`); set
|
||
`Api:CorsAllowedOrigins` (semicolon-separated exact origins). `AllowCredentials` is deliberately **not**
|
||
set — cross-origin cookie auth is impossible by design (a CSRF defense); cross-origin machine clients use
|
||
`X-Api-Key`. The policy permits `X-Api-Key`/`X-CSRF`/`If-Match` and exposes `ETag`.
|
||
- **ForwardedHeaders trust is unchanged from #285** (trust `X-Forwarded-*` from any peer by default, with a
|
||
warning; restrict via `ForwardedHeaders:KnownProxies`/`:KnownNetworks`). A stricter "ignore unless a proxy is
|
||
configured" default was considered for #295 but **reverted** — it would regress `/iptv` M3U/XMLTV/HLS
|
||
absolute-URL generation (which reads `Request.Scheme`/`Host`) for a proxied deployment that hasn't set
|
||
`KnownProxies`. **Strongly set `KnownProxies`/`:KnownNetworks`** when exposing ErsatzTV behind a proxy — it
|
||
also gives the #295 login rate limiter an unspoofable client IP and lets the session cookie be marked
|
||
`Secure` behind TLS.
|
||
- **Never add a side-effecting GET/HEAD under `/api`.** The filter's CSRF check only covers mutating verbs, so
|
||
a side-effecting GET is a CSRF vector the moment a session cookie is a normal credential (a `SameSite=Lax`
|
||
cookie rides a cross-site top-level GET navigation). The former `GET /api/v1/troubleshoot/playback.m3u8` (started
|
||
an FFmpeg workload) and the archive/sample GETs were **POST-ified for #301** (PR2): `POST
|
||
/api/v1/troubleshoot/playback/start` (returns `{ url }` pointing at the open `/iptv` manifest), `POST
|
||
/api/v1/troubleshoot/playback/archive`, `POST /api/v1/troubleshoot/playback/sample/{mediaItemId}` — so the standard
|
||
session-mutation CSRF gate covers them with no new filter machinery (removing HEAD also fixed a latent
|
||
`DeleteOnClose`-on-HEAD artifact-destruction bug). Any new endpoint that *does* something must be a mutating
|
||
verb; a GET must be a pure read.
|
||
- **The SPA cutover shipped in PR2 (#295 + #301).** The browser now authenticates with the session cookie only —
|
||
it no longer sends `X-Api-Key`; the machine key is external/MCP-only, surfaced read-only by the machine-key
|
||
screen (`GET /api/v1/auth/machine-key`). See `spa-conventions.md §5e` for the SPA seams (boot gate, central
|
||
`X-Csrf` on mutations, fetch-blob downloads).
|
||
|
||
**The OpenAPI "v1" document declares the machine posture by construction (#287).** An `ApiKey` security
|
||
scheme (`X-Api-Key`, `in: header`) is declared in `components.securitySchemes`, and
|
||
`ApiSecurityOperationTransformer` injects a per-operation `security` requirement + a documented `401`
|
||
for exactly the operations that require a credential — using the **same** shared predicate
|
||
`ApiAuthorizationFilter.EndpointRequiresKey(...)` the runtime filter enforces, so the spec can never drift from
|
||
enforcement. The document is generated against the effective default (`Api:RequireKeyForReads=true`), under
|
||
which every documented operation requires the credential; the browser-session path is an additional accepted
|
||
credential the spec (machine audience) needn't express. Two companion transformers run on the "v1" document
|
||
only:
|
||
`OperationIdOpenApiTransformer` synthesizes a stable `operationId` (from controller+action) for the ~90
|
||
operations that lacked a `Name=` — and disambiguates the HEAD/GET pairs that share a controller+action
|
||
**structurally, independent of ApiExplorer visitation order** (#197 Bundle C): when 2+ synthesized ops
|
||
share a base id it suffixes **each** by its verb (`…Get`/`…Head`), so discovery-order churn can't rename
|
||
a generated client method (a base id unique across the document stays unsuffixed; explicit `Name=` ids are
|
||
never touched). `ValidationProblemOperationTransformer` documents the
|
||
`400 ValidationProblemDetails` a model-binding/FluentValidation failure actually returns for any
|
||
body/param-binding operation. All four are registered in `Startup.cs` on "v1" only, mirroring
|
||
`NewtonsoftSchemaNamingTransformer`. **Route versioning shipped in #286**: the whole surface is mounted at
|
||
`/api/v1` (see §1 and `docs/decisions.md` 2026-07-13) — the OpenAPI document's `paths` are all `/api/v1/…`, and a
|
||
legacy unversioned `/api/*` caller is rewritten in-pipeline by `ApiVersionRewriteMiddleware`.
|