feat(api): #286 — mount the whole /api surface at /api/v1 #326

Merged
timothy merged 2 commits from fix/286-api-v1 into main 2026-07-13 00:52:26 +02:00
Owner

Closes the #197 C1 BLOCKER: /api/* was entirely unversioned, so the first breaking change would silently break the SPA and any external/MCP client. This is the Phase-2 contract-freeze gate — versioning can't be added compatibly after the contract ossifies.

What changed

  • Route sweep → /api/v1: all 251 controller route attributes, ~24 Location headers, the scanner callback URL (CallLibraryScannerHandler), and the Startup request-log path literal. Uniform — machine API, auth (/api/v1/auth/*), scanner loopback (/api/v1/scan/*), scripted-build (/api/v1/scripted/*) — so there's no unversioned corner and the compat rewrite needs no exclusion list.
  • ApiVersionRewriteMiddleware (before UseRouting): a legacy /api/* request is rewritten (not redirected) to /api/v1/* in-pipeline — method/body/auth/query survive — carrying RFC 8594 Deprecation: true + Link: rel="deprecation" (+ Sunset when Api:LegacyRoutesSunset is set). An already-versioned path passes through; a future /api/v2 is never forced back to v1.
  • Route-convention standardization (owner-requested): leading-slash absolute route per method, no class-[Route] — except the two controllers whose ~all actions share a parametrized {id} prefix (Scanner {scanId}, Scripted {buildId}), which keep a leading-slash class route with relative method segments. Enforced by ApiRouteVersioningTests — reflects over every [ApiController] action in Controllers.Api, computes the effective route, asserts ^/api/v\d+/. Browser-nav /auth/oidc/login (deliberately outside /api) is out of scope.
  • Regenerated v1.json (160 paths, all /api/v1), endpoint-index.md, v1.d.ts; swept 945 SPA request literals + the test mocks (incl. regex-form \/api\/ and a positional-segment URL parser).

Freeze semantics

/api/v1 is additive-only after this ships (renames/removals/retypes require /api/v2). The legacy-rewrite shim has a 2-release sunset window (owner decision); the actual removal is a tracked Phase-3 follow-up. No wire-DTO or status-code change — only the path prefix moved.

Verification

  • .NET: build clean, 1567 tests pass (incl. the new versioning + rewrite-middleware tests).
  • SPA: typecheck clean, 738 vitest, check:api + eslint green.
  • Format gate: touched .cs conform to .editorconfig (de-BOM'd 2 files, fixed pre-existing Name= spacing in touched controllers).
  • Rebased on origin/main (post-#238); decisions.md append-conflict resolved.

Docs (same PR)

decisions.md 2026-07-13, api-conventions.md §1/§9, rest-api.md / spa-conventions.md / blazor-route-parity.md / e2e-local.md / domain-model.md.

Live-E2E + independent adversarial review to follow on this head.

fixes #286
refs #197

🤖 Generated with Claude Code

Closes the #197 C1 **BLOCKER**: `/api/*` was entirely unversioned, so the first breaking change would silently break the SPA and any external/MCP client. This is the Phase-2 contract-freeze gate — versioning can't be added compatibly after the contract ossifies. ## What changed - **Route sweep → `/api/v1`**: all 251 controller route attributes, ~24 `Location` headers, the scanner callback URL (`CallLibraryScannerHandler`), and the `Startup` request-log path literal. **Uniform** — machine API, auth (`/api/v1/auth/*`), scanner loopback (`/api/v1/scan/*`), scripted-build (`/api/v1/scripted/*`) — so there's no unversioned corner and the compat rewrite needs no exclusion list. - **`ApiVersionRewriteMiddleware`** (before `UseRouting`): a legacy `/api/*` request is **rewritten** (not redirected) to `/api/v1/*` in-pipeline — method/body/auth/query survive — carrying RFC 8594 `Deprecation: true` + `Link: rel="deprecation"` (+ `Sunset` when `Api:LegacyRoutesSunset` is set). An already-versioned path passes through; a future `/api/v2` is never forced back to v1. - **Route-convention standardization** (owner-requested): leading-slash absolute route per method, no class-`[Route]` — except the two controllers whose ~all actions share a parametrized `{id}` prefix (`Scanner` `{scanId}`, `Scripted` `{buildId}`), which keep a leading-slash class route with relative method segments. Enforced by **`ApiRouteVersioningTests`** — reflects over every `[ApiController]` action in `Controllers.Api`, computes the effective route, asserts `^/api/v\d+/`. Browser-nav `/auth/oidc/login` (deliberately outside `/api`) is out of scope. - **Regenerated** `v1.json` (160 paths, all `/api/v1`), `endpoint-index.md`, `v1.d.ts`; **swept** 945 SPA request literals + the test mocks (incl. regex-form `\/api\/` and a positional-segment URL parser). ## Freeze semantics `/api/v1` is **additive-only** after this ships (renames/removals/retypes require `/api/v2`). The legacy-rewrite shim has a **2-release sunset window** (owner decision); the actual removal is a tracked Phase-3 follow-up. No wire-DTO or status-code change — only the path prefix moved. ## Verification - `.NET`: build clean, **1567 tests** pass (incl. the new versioning + rewrite-middleware tests). - SPA: typecheck clean, **738 vitest**, `check:api` + eslint green. - Format gate: touched `.cs` conform to `.editorconfig` (de-BOM'd 2 files, fixed pre-existing `Name=` spacing in touched controllers). - Rebased on `origin/main` (post-#238); decisions.md append-conflict resolved. ## Docs (same PR) `decisions.md` 2026-07-13, `api-conventions.md` §1/§9, `rest-api.md` / `spa-conventions.md` / `blazor-route-parity.md` / `e2e-local.md` / `domain-model.md`. Live-E2E + independent adversarial review to follow on this head. fixes #286 refs #197 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-13 00:32:40 +02:00
feat(api): #286 — mount the whole /api surface at /api/v1
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ef2bd65c27
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Live-E2E (local scripts/e2e-local.sh, head ef2bd65c)

App boots clean — the routing table builds with all /api/v1 routes (incl. the 40-method ScriptedScheduleController class-route pattern) and ApiVersionRewriteMiddleware registers, no route conflicts.

Check Result
GET /api/v1/health · /api/v1/channels · /api/v1/auth/config 200 (versioned routes resolve)
Legacy GET /api/health · /api/channels · /api/auth/config 200 + Deprecation: true + Link: </docs>; rel="deprecation" (in-pipeline rewrite)
GET /api/v2/health (future version) 404, no rewrite — never forced back to v1 ✓
Legacy GET /api/channels no key 401 + Deprecation — rewrite happens then the auth gate still applies (no bypass) ✓
/app/ in headless Chromium SPA renders (title ChicoryTV), 0 console errors, boot request = GET /api/v1/auth/config → 200 (no unversioned 404s)

Sunset header correctly absent (unset Api:LegacyRoutesSunset); Deprecation/Link always present on rewritten requests.

Independent adversarial review (cold fork + Codex, scoped review-only) in flight on this head; will post Review-verdict when they land.

## Live-E2E ✅ (local `scripts/e2e-local.sh`, head `ef2bd65c`) App boots clean — the routing table builds with all `/api/v1` routes (incl. the 40-method `ScriptedScheduleController` class-route pattern) and `ApiVersionRewriteMiddleware` registers, no route conflicts. | Check | Result | |---|---| | `GET /api/v1/health` · `/api/v1/channels` · `/api/v1/auth/config` | **200** (versioned routes resolve) | | Legacy `GET /api/health` · `/api/channels` · `/api/auth/config` | **200** + `Deprecation: true` + `Link: </docs>; rel="deprecation"` (in-pipeline rewrite) | | `GET /api/v2/health` (future version) | **404**, no rewrite — never forced back to v1 ✓ | | Legacy `GET /api/channels` **no key** | **401** + Deprecation — rewrite happens *then* the auth gate still applies (no bypass) ✓ | | `/app/` in headless Chromium | SPA renders (title ChicoryTV), **0 console errors**, boot request = `GET /api/v1/auth/config → 200` (no unversioned 404s) | `Sunset` header correctly absent (unset `Api:LegacyRoutesSunset`); `Deprecation`/`Link` always present on rewritten requests. Independent adversarial review (cold fork + Codex, scoped review-only) in flight on this head; will post `Review-verdict` when they land.
timothy added 1 commit 2026-07-13 00:41:35 +02:00
fix(api): #286 review — allowlist non-/api routes in the versioning test; base-url-aware deprecation Link
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m49s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m43s
682dceec8f
Cold-fork + Codex review of PR #326:
- ApiRouteVersioningTests: iterate IRouteTemplateProvider (covers a
  template-less [HttpGet] paired with an action-level [Route]) and assert
  any non-/api route against an explicit KnownNonApiRoutes allowlist
  instead of silently skipping — an accidental absolute non-/api route
  (which would also escape ApiAuthorizationFilter's /api-scoped gate) now
  fails the test.
- ApiVersionRewriteMiddleware: root the deprecation Link at Request.PathBase
  so it stays correct under ETV_BASE_URL (</etv/docs>, not host-root </docs>).

refs #286 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Review loop complete

  • Cold fork (base diff ef2bd65c): MERGEABLE-WITH-NITS — confirmed no missed/corrupted URLs across the ~250-route sweep, auth/CSRF not bypassed by the rewrite, middleware position correct, docs + OpenAPI regenerated.
  • Codex (independent, base diff): BLOCKED on two items — (1) the versioning test silently skipped non-/api routes (a stray absolute non-/api route would escape both the test and ApiAuthorizationFilter); (2) the deprecation Link broke under ETV_BASE_URL. It independently re-verified every internal producer (11 scanner consumers → one GetBaseUrl; scripted → regenerated client; no stray).
  • Both fixed in 682dceec: test iterates IRouteTemplateProvider + asserts non-/api routes against an explicit KnownNonApiRoutes allowlist; Link rooted at Request.PathBase.
  • Fix-commit re-review (cold, scoped to 682dceec): verified both fixes correct/complete/non-vacuous, no new bug.

Live-E2E (versioned routes, legacy rewrite + Deprecation header, /api/v2 passthrough, auth-not-bypassed, SPA boot) passed on this head's parent and is unaffected by the test-only + Link-only fix delta.

Review-verdict: MERGEABLE @ 682dceec

## Review loop complete - **Cold fork** (base diff `ef2bd65c`): MERGEABLE-WITH-NITS — confirmed no missed/corrupted URLs across the ~250-route sweep, auth/CSRF not bypassed by the rewrite, middleware position correct, docs + OpenAPI regenerated. - **Codex** (independent, base diff): BLOCKED on two items — (1) the versioning test silently skipped non-`/api` routes (a stray absolute non-`/api` route would escape both the test and `ApiAuthorizationFilter`); (2) the deprecation `Link` broke under `ETV_BASE_URL`. It independently re-verified every internal producer (11 scanner consumers → one `GetBaseUrl`; scripted → regenerated client; no stray). - **Both fixed in `682dceec`**: test iterates `IRouteTemplateProvider` + asserts non-`/api` routes against an explicit `KnownNonApiRoutes` allowlist; `Link` rooted at `Request.PathBase`. - **Fix-commit re-review** (cold, scoped to `682dceec`): verified both fixes correct/complete/non-vacuous, no new bug. Live-E2E (versioned routes, legacy rewrite + `Deprecation` header, `/api/v2` passthrough, auth-not-bypassed, SPA boot) passed on this head's parent and is unaffected by the test-only + Link-only fix delta. Review-verdict: MERGEABLE @ 682dceec
timothy merged commit 682dceec8f into main 2026-07-13 00:52:26 +02:00
Sign in to join this conversation.