The three recurrence arrays are read CONJUNCTIVELY by AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date. `?? []` on an omitted array therefore returned HTTP 200 while storing an alternate-schedule or template item that could never apply, silently -- while the read side (#823) already read a NULL column as the All*() sets. Absent and explicitly-empty are two different requests and get two answers: ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(), the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422 naming the consequence, via RecurrenceSetBounds called from both replace handlers. The rejection lives in the handlers, not the controller, because api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable. That comparison needs the stored row. The validated set is derived from `incoming`, so the highest-Index catch-all -- whose recurrence the handler discards -- is excluded by construction. Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint reddened sets; live-E2E against a real instance confirmed an OMITTED property round-trips as unrestricted (the Newtonsoft missing-property chain unit tests cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted. Cross-family cold review BLOCKED the first implementation with 3 findings, all real and all fixed; re-review returned MERGEABLE. Follow-up #894 filed: the SPA can still build the empty state the server rejects. fixes #880 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 KiB
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, withName = "..."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 ofApiControllerSecurityTests) reflects over every[ApiController]action inControllers.Apiand 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}")]) andScriptedScheduleController([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'sGET /auth/oidc/login) is out of scope for the versioning rule. Seedocs/decisions.md2026-07-13 (#286) for the versioning contract (additive-only after freeze; the legacy/api/*→/api/v1/*in-pipeline rewrite inApiVersionRewriteMiddleware).[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 inTemplateController.csandLogsController.cshas 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/ToErrorResultusage. - Paged GET with clamped params:
ErsatzTV/Controllers/Api/LogsController.cs—pageNumclamped viaMath.Max(0, pageNum),pageSizeviaMath.Clamp(pageSize, 1, MaxPageSize)(MaxPageSize = 100). Any new paged endpoint should clamp the same way — don't trust client input for page math.pageNumis 0-based across the whole surface (the first page is0) and the offset is always derived from the clampedpageSize, so an over-largepageSizeyields 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). Put that description on the parameter itself with[Description("...")](System.ComponentModel, on the[FromQuery]parameter) so it reaches the generated OpenAPI document — an attribute-free paging parameter is emitted with no description at all, leaving a REST consumer to infer the base fromdefault: 0(ersatztv#633). State the endpoint's own cap, never one global number: the caps differ (100 typical, 200 auto-tune members, 1000search/all-items).OpenApiPagingContractTestspins this and names the expected set of paged operations, so a new paged endpoint fails until it is added there with descriptions. Seeapi.paging-zero-based. The handler behind it must compute its total from the SAME query it pages. Build oneIQueryable, apply every filter to it, then take bothCountAsyncand the page from that object — neverdbContext.<Set>.CountAsync(ct)beside a separate page query, and never a secondCountAsync(pred, ct)restating the predicate. Take the shape even before the first filter exists — a handler with no filter today is where the drift is introduced tomorrow. The drifted state is silent and shaped like working software: the page is right, the total is wrong, and the client believes the total, so the SPA paginates to pages that can never fill and an MCP caller pages toward a completeness target it cannot reach. Eager-loading.Include(...)belongs on the page chain only, appended after the count — aCOUNTdoes not materialize the graph. Seeapi.paged-count-matches-page-query(ersatztv#690, #758). - Sortable GET with allow-listed sort params: same file —
sortField/sortDirectionare normalized against a fixed allow-list (AllowedSortFields) rather than trusted or rejected with a 422: an unrecognizedsortFieldsilently falls back to the default field, an unrecognizedsortDirectionfalls 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 arerecords 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-mirrorsSettings/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-nullstringmember emits a spuriousnullable: truein the schema — so add#nullable enableat the top of every new response-model file and mark a member?only when the mapper can actually emit null.FFmpegProfileResponseModel.csis no longer an exception. - Raw-VM wrapping (#288). The last controllers returning Application ViewModels directly were
wrapped, so no
/api/*action returns a*ViewModeltype anymore:CollectionController→MediaCollectionResponseModel(Id, Name, CollectionType, UseCustomPlaybackOrder— drops theMediaCardViewModelscaffolding and the header-onlyVersion),ScheduleController→ProgramScheduleResponseModel(VM minusVersion),SmartCollectionController→ the existingSmartCollectionResponseModel,ResolutionController.GetResolutionByName→ResolutionResponseModel, and the channel detail GETs/writes →ChannelDetailResponseModel(the full editable field set the SPA channel editor needs — distinct from the lean listChannelResponseModel; drops only the derivedwebEncodedName). When wrapping, expose exactly what the client reads: a leaner projection is right for a list, a faithful detail projection for an editor.
- As of #288, all response-model files under
- Request DTOs:
ErsatzTV/Controllers/Api/Requests/*Request.cs. No#nullable enablepragma — match the existing files (e.g.CreateFFmpegProfileRequest.cs,ReplaceScheduleItemsRequest.cs). A request record typically carries aToCommand()(orToCommand(int parentId),ToReplaceCommand(int index)) method that maps it to the Application-layer command type. ErsatzTV.Applicationhas no nullable context (no<Nullable>= C# defaultdisablefor 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 inErsatzTV.Applicationbut 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: useErsatzTV.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'swatermarks/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
Statusplus aFaults/ detail array, both typed as plainstring/string[]backed by aconst stringclass (e.g.ChannelHealthStatus,ChannelFault), not a C# enum — keeps the OpenAPI schema a barestring/string[]and lets the SPA hand-maintain its own TS union, exactly likeChannelPreviewAvailability. Exemplar:ChannelHealthResponseModel({Status, Faults[], PlayoutCount, BrokenSourceItemCount}) nested ashealthonChannelResponseModel/ChannelDetailResponseModel— computed read-time from the built playout timeline (never cached at build time, since the underlyingMediaItem.Stateflips on scan) via one bounded aggregate query, not per-row N+1. Seedocs/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, seedocs/decisions.md2026-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? nameon the action; filter server-side only when it has a value. Exemplar:?fillerKind=onGET /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=onPOST /api/v1/libraries/{id}/scan(§3b), and?refresh=onGET /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 (seedecisions.md2026-07-19, #431). - "Clear to none" vs "inherit" on a coalescing DTO: a typed
clearenum list, not null. When a create/patch DTO resolves a field asrequest.X ?? inherited.X(e.g.advanced.X ?? template.X),nullalready means inherit, so it cannot also mean set to none. Add an optionalclearfield 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 inErsatzTV.Core(not the Application command) soStartup.UseStringEnumSchemasrenders it as a string enum in the spec — an Application-layer enum shows as a bareinteger. Exemplar:CreateChannelFromLineupClearFieldonPOST /api/v1/channels/from-lineup(decisions.md2026-07-21,api.from-lineup-clear-to-none, #135). - Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.
/api/v1is 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 flatstring? Link(still populated) and addedRemediation { Kind, Target }(a nested model with an in-app-route-vs-external-doc kind) plusBrief.Remediation.Kindis a mapped string ("ExternalDoc"/"AppRoute"), not a wire enum — same pattern asStatus. Seedecisions.md2026-07-17 (#164).
2a. Flattening a tagged-union selection (read path)
Several DTOs flatten a "exactly one of these navigations is populated" tagged union to a single
selectedId + selectedName pair (RerunCollectionResponseModel, and the playlist-item shape).
Two rules, both learned from #671, where the list endpoint returned a null selection for every
row and the detail GET 500'd for two of its media types:
- One include chain per projected aggregate, shared by every handler that projects it. Put it in
a
<Aggregate>QueryExtensionsextension method and call it from the list handler and the by-id handler. Exemplars:RerunCollectionQueryExtensions.IncludeSelectionDetails(),ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails(). Two hand-maintained chains drift, and the one that drifts is usually the paged list, whose rows are individually less obviously wrong. Applying it beforeSkip/Takeis fine — EF applies the includes to the paged subquery, so the cost is bounded byPageSize, not by the table. - The id and the name must not share a single point of failure. When both are read off the same
eager-loaded navigation, the id is only ever as available as the name — so an un-included type
doesn't merely render an unlabelled badge, it drops the selected id, and an editor that
round-trips that id silently clears the user's stored selection. Accordingly a media-item
flattening switch never ends in
_ => null: an unrecognized subtype keeps its id and takes a conspicuous[unsupported media type: X]name. Throwing is the wrong lever — it would fail an entire paged GET over one unreadable row. The shared switch isMediaCollections.Mapper.ProjectMediaItemToViewModel.
Corollary for the mappers themselves: MediaItems.Mapper's projections are reached from handlers
whose include chains differ, so every metadata navigation is read through Optional(...).Flatten()
and degrades to the "???" placeholder rather than throwing. A bare x.Season.Show.ShowMetadata
inside a projection is a latent 500 on some other caller's GET.
And it is not only navigations. SongMetadata.Artists is a nullable EF primitive collection
(a JSON array in one column), which FallbackMetadataProvider leaves unassigned for a song whose
tags failed to read — and string.Join throws ArgumentNullException on a null sequence, not a
NullReferenceException. Adding an include is therefore not automatically safe: it can promote a
latent throw on a previously-unloaded member into a live 500 that fails the whole page. When you
widen an include chain, audit what the newly-reachable projection dereferences.
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-allstays 202; its handler (ResetAllPlayoutsHandler) skips locked playouts, matching Blazor + the handler semantics. Only per-id mutations 409. As of #235 the handler returns aResetAllPlayoutsResult(QueuedPlayoutIds/SkippedLocked/SkippedUnsupported) and the controller returns the 202 with aResetAllPlayoutsResponseModelbody reporting what was queued vs. skipped (locked, or an unsupportedExternalJson/Nonekind) — 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
IsLockedboolean onto the list DTO (PlayoutListItemResponseModel, set fromIsPlayoutLockedin 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/resetresolves the channel's playout by the immutable channelId(GetPlayoutIdByChannelId), guards onIsPlayoutLocked→ 409, queues aBuildPlayout→AcceptedResult, and 404s when the channel has no playout). It is keyed on{id:int}, not{channelNumber}— the single-item Channel admin contract keys onId, never the user-mutableNumber(re-keyed in #197 Bundle C; seedocs/decisions.md2026-07-12). The broadcast-side lookup (GetPlayoutIdByChannelNumber, used byHlsSessionWorker) 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
IEntityLockerlock (not just a controller pre-check) and loses the race, returnnew LockedError(...)(ErsatzTV.Core/Errors/LockedError.cs, sibling ofNotFoundError/PreconditionFailedError) from the handler —ToErrorResult()maps it to 409 automatically. Exemplar:PrepareTroubleshootingPlaybackHandler—TroubleshootControllerdoes a cheapIsTroubleshootingPlaybackLocked()pre-check (advisory, §3a's check-then-act caveat applies), but the handler's ownLockTroubleshootingPlayback()is the atomic acquire; if that loses the race it returnsLockedError, 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 422BaseError.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 WriteAsynces 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 validationBaseError(422). - Dictionary-indexer lookups (
dict[key]) that can throwKeyNotFoundException→ guard or useTryGetValue. - 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/TimeSpaninputs 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#nullablecontext (§2), so astring Namebindsnullfromname: null/an omitted field and there is no implicit[Required]; a raw.Length/.Trim()throwsNullReferenceException→ an unhandled 500 (there is no global exception filter). Validate names null-safe: reuse theValidators.NotEmpty(x => x.Name).Bind(_ => x.NotLongerThan(50)(x => x.Name))combinator (ErsatzTV.Application/Validators/StringValidation.cs; both are null-safe viaOptional), the same pattern the group-create handlers already use — or at minimum guardstring.IsNullOrWhiteSpace(name)before any member access. Fixed across 10 create/replace handlers in issue #172 (wasif (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):
- Dispatch the write command; on
Left, return the error — nothing is enqueued. - 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).
- Iterate the reloaded rows and, per row that needs a sync,
LockLibrary(id)— skip rows whose lock is already held (mirrors Blazor'sif (Locker.LockLibrary(id))loop; no 409 for the save itself, only a silent skip for that one row's sync). - Enqueue the background message(s) for that row; if a multi-message enqueue can throw partway
through, wrap it in
try/catchand release the lock in thecatch(compensating unlock) before rethrowing — the standardEnqueueWithTraktLockone-lock-⇄-one-release discipline (§3b), just invoked from inside a write endpoint instead of a dedicated "start background job" endpoint. - 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).
3d. Bound a consequential numeric field with a 422 — never accept-then-rewrite
A write path that accepts an out-of-range number, stores a different one and returns 200 teaches
the caller nothing and leaves the stored config no longer describing the behavior: the SPA keeps
rendering what was typed while the pipeline uses the substitute, and a machine client that PUTs a
value reads back another. Validate to the documented range and return 422 naming the bound, with
the consequence of exceeding it in the message ("…leaves the QSV upload pool with too little headroom
and the transcode writes nothing at all"), so the error teaches the bound instead of hiding it.
Three rules that come with it (exemplar: ErsatzTV.Application/FFmpegProfiles/FFmpegProfileBounds.cs,
ersatztv#735):
- Put the constants where the renderer reads them, and validate against those —
FFmpegStateownsMinimumQsvExtraHardwareFrames,Minimum/MaximumReadRateand the defaults, and the write-path validator reads those symbols rather than restating numbers. The SPA cannot: it restates each bound as a literal (web/src/screens/ffmpegProfileDraft.ts), and nothing pins the two together, so raising a server bound leaves every test green while the form keeps enforcing the old one. Mirror the value AND the wording of the server's message, and treat the drift as a known residual rather than assuming the literal is checked. - Keep the render-time clamp as well. It is what makes the change migration-free: rows written before the validation existed, or out of band, still cannot reach FFmpeg unbounded. Validation is the primary guard; the clamp is belt-and-braces, and both need a test.
- On update, reject a NEWLY submitted out-of-range value, not an unchanged legacy one. The SPA
sends the whole profile back on every edit, so rejecting a stored-but-out-of-range value would
make an old row uneditable over a field the operator never touched — and, when the field is
conditionally rendered, cannot even see. Compare against the stored value and let an unchanged one
through — and mirror the exemption in the client, or the form blocks a save the server would
have accepted and the row is uneditable in the surface that matters (
validate(draft, stored)inffmpegProfileDraft.ts; the add/copy path passes no stored draft and stays strict, matching the create handler'sstored: null).
null keeps meaning unset, resolved by the renderer to the value it used before the field was
configurable — never materialized into a stored number on save, so an untouched profile behaves
identically. Document the range in the schema with [property: Description("…")] on the request
record's positional parameter (System.ComponentModel); it renders into v1.json.
3e. An ABSENT collection means unrestricted; an explicitly EMPTY one is rejected
A nullable collection on a write path carries two different requests that are easy to collapse into one, and collapsing them is how ersatztv#880 shipped a 200 that stored a row which could never apply. Decide both, separately:
- Absent (the property is missing, or explicitly
null— Newtonsoft maps both tonull) means the client is not expressing a restriction. Normalize it to the permissive value, which is whatever the READ side already substitutes for the same absence. It must not become the empty set: for a filter, empty is the maximally restrictive value, so?? []silently inverts the request. - Explicitly empty (
[]) is a different, well-formed request. If the empty set has no meaningful outcome — a conjunctive filter that then matches nothing — reject it with a 422 naming the consequence, per §3d. Do not accept-then-rewrite it into the permissive value: that would make[]and absence indistinguishable again, in the other direction.
Distinguishing them requires the request record's property to be nullable (List<T>?, with a
per-file #nullable enable where the project has annotations off), because ?? … on a
non-nullable-annotated List<T> cannot tell absence from empty. Normalization belongs in the request
record's ToReplaceItem; rejection does not belong beside it. Three rules that come with it:
- Say "send
null", not "omit the property". Making a C# property nullable does NOT make it optional in the generated schema: all three recurrence properties are still listed inrequiredinv1.json, with type["null","array"]. A client generated from the published contract therefore cannot omit them, and an error message telling it to would be instructing a schema violation. Omission still works at runtime;nullis the form that is also contract-legal. - Validate where the STORED value is in hand — the handler, not the controller. §3d's rule that an
unchanged bad value must still be accepted applies here in its sharpest form: these are whole-list
replace PUTs, so rejecting a pre-existing empty set would make every other item in the list
uneditable over a row the operator never touched. That comparison needs the stored row, which the
controller does not have and the handler already loaded. Exemplar:
RecurrenceSetBounds(ErsatzTV.Application/Scheduling), one validator called from both replace handlers — the same shape asFFmpegProfileBounds. The exemption is per field, not per row: a row grandfathered onDaysOfWeekstill cannot newly emptyMonthsOfYear. - Derive the validated set from the list the handler actually writes. In the alternate-schedule
path that is
incoming, which excludes the highest-Indexcatch-all — the handler discards that item's recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is false for that item. Walking the same list the writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is the catch-all" in a second place (api.put-replace-index-order).
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/uploadsderives the content type from the actual bytes viaErsatzTV.Core/Images/ImageContentTypes.DetectContentType(SkiaSharp header sniff — no full decode), rejecting non-images 422. That helper (Acceptedset +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-Typefrom the stored file; there is no?contentType=query parameter. Never add one back — a client must not be able to choose theContent-Typeof an unauthenticated response. - Persisted
{path, contentType}DTOs (logo/watermark) run their content type throughArtworkContentTypeModel.Sanitized()before storage, blanking anything outside the allow-list. Seedocs/decisions.md2026-07-12 (#283) for the fuller rationale.
5. OpenAPI regeneration — commit both generated artifacts
After any controller/DTO change:
dotnet build ErsatzTV.sln(normal build first). Do not skip this.update-openapi.shrunsdotnet-getdocumentagainst the already-builtErsatzTV.dll; an incremental-t:GenerateOpenApiDocumentswill not notice a stale assembly and will silently serialize the old spec (e.g. dropping thesecurity/401blocks the auth transformers add). Build first so the assembly is current. (CI is immune — a fresh checkout has nobin/and always compiles.)./scripts/update-openapi.sh— runsdotnet build -t:GenerateOpenApiDocumentsfromErsatzTV/, regeneratingErsatzTV/wwwroot/openapi/v1.jsonanddocs/endpoint-index.md.cd web && npm run generate:api— runsscripts/generate-openapi-types.mjs, regeneratingweb/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 thetestjob, always) = regenerate.d.tsfrom the committedv1.json+git diff --exit-code.- the
api-docsjob (ersatztv#303 H4/H5) — blocking, fires when a PR diff touchesErsatzTV/Controllers/Api/**orErsatzTV.Core/Api/**: it rebuildsv1.json/v1.d.ts/endpoint-index.mdfrom 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) 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.
Bounded best-effort for list-valued fields (#578): artist and album_artist are backed (wholly
or partly) by SongMetadata.Artists/AlbumArtists, which EF maps as primitive collections — one
JSON array per row in a single column, with no server-side projection on either provider. album_artist
therefore no longer 404s, and artist now also covers free-text music-video (MusicVideoArtist) and
song credits, not only entity artists. Those rows are read by a keyset page whose only condition is the
cursor — no residual predicate that could discard a row — and filtered in memory, bounded at 20,000
logical rows per request; so on a larger library the
response may be a bounded subset of the matches — bounded in LOGICAL ROWS, which is not the same as
bounded work or bytes. Say so in the [EndpointDescription] of any endpoint that adopts this shape.
Three rules generalize beyond this endpoint.
LIMITbounds the OUTPUT, not the row count, whenever a RESIDUAL predicate is present. The distinction is not "predicate vs none" — a keyset cursor is a predicate. It is that a seekable predicate on the ordering key positions the scan and never discards a row, while a residual predicate (LIKE,LOWER,IS NOT NULL) throws away rows the engine already produced, soLIMITtruncates the survivors and says nothing about how many were produced — a query matching nothing must examine every eligible row before it can return an empty page. To bound rows, drop the residual predicate, page by row position over the primary key, and filter in memory. This endpoint got it wrong four times: bounding the result, then candidates returned, thenIdkeyspace width (keyspace is not rows — one live row atId20001 behind 20,000 deleted ones reads nothing), before arriving at "cursor only". And scope the resulting claim to LOGICAL ROWS. It is not bounded physical work: MySQL still traverses deleted-but-unpurged index records, so deletion history keeps affecting cost, and an unrestrictedTEXTcolumn spills to overflow pages so a row count implies no byte or page-read count. A SQL-string assertion pins none of that — not a plan, not visibility work, not I/O.- A SQL pre-filter under an in-memory exact filter may over-match but must never under-match — and
that licence is void the moment the candidate set is truncated. Widening the predicate then starves
the budget with rows that cannot match. If you find yourself proving a superset property to keep a
pre-filter honest, consider deleting the pre-filter instead: here it removed a JSON-escaping bug
class, an exhaustive Unicode sweep and an
ESCAPEportability workaround along with it. - Prefix matching, dedup and ordering must be ordinal, not current-culture (
OrdinalIgnoreCase,StringComparer.Ordinal):UseRequestLocalizationhonoursAccept-Language, soToLower()and the default linguisticStartsWith(string)let a caller change the result by changing a header. Scope the claim to the stage that actually holds it — a value set that a databaseLOWER/DISTINCT/ORDER BY/LIMITalready filtered and truncated is not ordinal no matter what runs after it, and saying otherwise in an[EndpointDescription]publishes a false contract (ersatztv#668).
Full rationale, the measured transfer cost, the four-attempts table and the rejected
normalized-side-table alternative (ersatztv#669): api.search-field-values-sources (supersedes
api.search-field-values).
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 (mockIMediator). Exemplar:ErsatzTV.Tests/Controllers/TemplateControllerTests.cs— asserts every route via aShouldHaveActionRoute(actionName, verb, path)helper (route-table regression net), then per-action tests asserting the DTO shape returned and the exactmediator.Received(1).Send(Arg.Is<Command>(...))call.PlayoutControllerTests.csis 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 theErsatzTV.Controllers.Apinamespace to find every concrete,[ApiController]-marked controller class — no manual registry to maintain; a new controller is covered automatically. (It intentionally does not filter onControllerBase: several API controllers — including the lone exemptScannerController— do not derive from it, and aControllerBasefilter 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 globalApiKeyAuthorizationFilter(or has an explicit[SkipApiKeyAuthorizationAttribute]exemption; onlyScannerControlleris 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 thatScannerControllercarries[LocalhostOnly]— reflectively, so the tier can't silently drop a gate.ApiKeyAuthorizationFilterTests.csunit-tests the filter itself: writes are always fail-closed, reads are gated whenApi:RequireKeyForReads(default true) or[RequiresApiKey],OPTIONSpreflight and non-/apipaths 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), thenPRAGMA foreign_keys=OFFso 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.Factorywhere anIDbContextFactory<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):
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 getsNone. - Background-job handlers keep their token.
BuildPlayoutHandlerand 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 normalizes0→null so the handler contract is two-state). Never fabricate an id.- When any request item carries an id,
ReplaceProgramScheduleItemsHandlerreconciles 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.Indexis 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:
- a matching machine
X-Api-Keyheader (MCP / external clients — issue #197 Bundle A), or - 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:RequireKeyForReadsis enabled, the default (true).OPTIONSpreflight is exempt. - The effective machine key comes from
IApiKeyProvider(ErsatzTV/Services/ApiKeyProvider.cs):Api:WriteKeyif configured, else a key persisted atFileSystemLayout.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-CSRFheader (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 bySameSite=Lax+ CORS withoutAllowCredentials). Key-authed requests are exempt. When you add a SPA mutation, sendX-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 setsApi: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.ApiControllerSecurityTestsasserts 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.ApiControllerSecurityTestsasserts 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 isGET /auth/oidc/login(outside/api).login/setup/passwordcarry a per-IP rate limit ([EnableRateLimiting("auth")], 10 / 5 min). The local admin is a single credential inConfigElementrows (AuthLocalAdminUsername/AuthLocalAdminPasswordHash(PBKDF2) /AuthSecurityStamp) — no DB migration; a password change rotates the stamp, revoking sessions via the cookieOnValidatePrincipal. Recovery/bootstrap without the browser: setAuth:LocalAdmin:Password(+ optional…:Username, defaultadmin) and restart (LocalAdminSeedServicereseeds + 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); setApi:CorsAllowedOrigins(semicolon-separated exact origins).AllowCredentialsis deliberately not set — cross-origin cookie auth is impossible by design (a CSRF defense); cross-origin machine clients useX-Api-Key. The policy permitsX-Api-Key/X-CSRF/If-Matchand exposesETag. - ForwardedHeaders trust is unchanged from #285 (trust
X-Forwarded-*from any peer by default, with a warning; restrict viaForwardedHeaders:KnownProxies/:KnownNetworks). A stricter "ignore unless a proxy is configured" default was considered for #295 but reverted — it would regress/iptvM3U/XMLTV/HLS absolute-URL generation (which readsRequest.Scheme/Host) for a proxied deployment that hasn't setKnownProxies. Strongly setKnownProxies/:KnownNetworkswhen exposing ErsatzTV behind a proxy — it also gives the #295 login rate limiter an unspoofable client IP and lets the session cookie be markedSecurebehind 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 (aSameSite=Laxcookie rides a cross-site top-level GET navigation). The formerGET /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/iptvmanifest),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 latentDeleteOnClose-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). Seespa-conventions.md §5efor the SPA seams (boot gate, centralX-Csrfon 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.