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.
Regeneratedv1.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).
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)
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>
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
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>
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 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 main2026-07-13 00:52:26 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
/api/v1: all 251 controller route attributes, ~24Locationheaders, the scanner callback URL (CallLibraryScannerHandler), and theStartuprequest-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(beforeUseRouting): a legacy/api/*request is rewritten (not redirected) to/api/v1/*in-pipeline — method/body/auth/query survive — carrying RFC 8594Deprecation: true+Link: rel="deprecation"(+SunsetwhenApi:LegacyRoutesSunsetis set). An already-versioned path passes through; a future/api/v2is never forced back to v1.[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 byApiRouteVersioningTests— reflects over every[ApiController]action inControllers.Api, computes the effective route, asserts^/api/v\d+/. Browser-nav/auth/oidc/login(deliberately outside/api) is out of scope.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/v1is 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).check:api+ eslint green..csconform to.editorconfig(de-BOM'd 2 files, fixed pre-existingName=spacing in touched controllers).origin/main(post-#238); decisions.md append-conflict resolved.Docs (same PR)
decisions.md2026-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
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>Live-E2E ✅ (local
scripts/e2e-local.sh, headef2bd65c)App boots clean — the routing table builds with all
/api/v1routes (incl. the 40-methodScriptedScheduleControllerclass-route pattern) andApiVersionRewriteMiddlewareregisters, no route conflicts.GET /api/v1/health·/api/v1/channels·/api/v1/auth/configGET /api/health·/api/channels·/api/auth/configDeprecation: true+Link: </docs>; rel="deprecation"(in-pipeline rewrite)GET /api/v2/health(future version)GET /api/channelsno key/app/in headless ChromiumGET /api/v1/auth/config → 200(no unversioned 404s)Sunsetheader correctly absent (unsetApi:LegacyRoutesSunset);Deprecation/Linkalways present on rewritten requests.Independent adversarial review (cold fork + Codex, scoped review-only) in flight on this head; will post
Review-verdictwhen they land.Review loop complete
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./apiroutes (a stray absolute non-/apiroute would escape both the test andApiAuthorizationFilter); (2) the deprecationLinkbroke underETV_BASE_URL. It independently re-verified every internal producer (11 scanner consumers → oneGetBaseUrl; scripted → regenerated client; no stray).682dceec: test iteratesIRouteTemplateProvider+ asserts non-/apiroutes against an explicitKnownNonApiRoutesallowlist;Linkrooted atRequest.PathBase.682dceec): verified both fixes correct/complete/non-vacuous, no new bug.Live-E2E (versioned routes, legacy rewrite +
Deprecationheader,/api/v2passthrough, 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