diff --git a/devops/compose/README.md b/devops/compose/README.md index 390ea59fb..4079ddf8b 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -134,11 +134,14 @@ external/bootstrap surfaces that cannot participate in router registration yet ( flows, Rekor, and static/platform bootstrap assets). The local route table also carries a small set of explicit precedence rules that must stay ahead of the -generic `^/api/v1/{service}` matcher. Platform-owned surfaces such as `/api/v1/aoc/*` and -`/api/v1/administration/*` resolve directly to `platform`, and browser compatibility prefixes such as -`/doctor/*` and `/scheduler/*` are segment-bound before they strip the frontdoor prefix for dispatch. -That keeps the target services on their canonical `/api/v1/doctor/*` and `/api/v1/scheduler/*` paths -without hijacking frontend assets like `doctor.routes-*.js` or `scheduler-ops.routes-*.js`. +generic `^/api/v1/{service}` and `^/api/v2/{service}` matchers. Platform-owned surfaces such as +`/api/v1/aoc/*`, `/api/v1/administration/*`, and the aggregated v2 read models +`/api/v2/context/*`, `/api/v2/releases/*`, `/api/v2/security/*`, `/api/v2/topology/*`, +`/api/v2/evidence/*`, and `/api/v2/integrations/*` resolve directly to `platform`. Browser +compatibility prefixes such as `/doctor/*` and `/scheduler/*` are segment-bound before they strip the +frontdoor prefix for dispatch. That keeps the target services on their canonical `/api/v1/doctor/*` +and `/api/v1/scheduler/*` paths without hijacking frontend assets like `doctor.routes-*.js` or +`scheduler-ops.routes-*.js`. ```bash # Default frontdoor route table diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 8ec1286b7..04684a616 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -89,6 +89,7 @@ { "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" }, { "Type": "Microservice", "Path": "^/api/v2/security(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/security$1" }, { "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" }, + { "Type": "Microservice", "Path": "^/api/v2/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/evidence$1" }, { "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" }, { "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" }, diff --git a/docs/implplan/SPRINT_20260310_022_Router_platform_v2_evidence_frontdoor_mapping.md b/docs/implplan/SPRINT_20260310_022_Router_platform_v2_evidence_frontdoor_mapping.md new file mode 100644 index 000000000..044d799e0 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_022_Router_platform_v2_evidence_frontdoor_mapping.md @@ -0,0 +1,48 @@ +# Sprint 20260310-022 - Router Platform V2 Evidence Frontdoor Mapping + +## Topic & Scope +- Restore the explicit frontdoor contract for `/api/v2/evidence/*` so the environment posture and Mission Control evidence read models resolve through Platform instead of the generic v2 catch-all. +- Keep the work inside the Router route table and regression coverage, with only the minimal compose/docs coordination edits required to keep scratch setup deterministic. +- Working directory: `src/Router`. +- Allowed coordination edits: `devops/compose/router-gateway-local.json`, `docs/modules/router/architecture.md`, `devops/compose/README.md`, `docs/implplan/SPRINT_20260310_022_Router_platform_v2_evidence_frontdoor_mapping.md`. +- Expected evidence: focused router tests, live frontdoor curl proof, and Playwright reruns showing the prior `/api/v2/evidence/packs` 404 is gone. + +## Dependencies & Concurrency +- Depends on the local compose stack being reachable through `https://stella-ops.local`. +- Safe parallelism: avoid unrelated Platform and Web feature edits while this router iteration is in progress. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/modules/router/architecture.md` +- `docs/implplan/SPRINT_20260310_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md` + +## Delivery Tracker + +### ROUTER-EVIDENCE-V2-001 - Restore explicit v2 evidence route ownership +Status: DONE +Dependency: none +Owners: QA, Developer, Architect +Task description: +- Add the missing explicit `/api/v2/evidence*` mapping to the Platform frontdoor route table in both appsettings and local compose so the request no longer falls through to the generic `^/api/v2/{service}` route. +- Extend the router regression tests to prove the specific v2 evidence mapping wins over the generic matcher. + +Completion criteria: +- [x] Router configs include explicit `/api/v2/evidence*` mapping to `platform`. +- [x] Regression tests cover config presence and route-resolution precedence. +- [x] Live frontdoor requests to `/api/v2/evidence/packs` no longer return router-level `404`. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after live Mission Control QA surfaced repeated `404` responses from `/api/v2/evidence/packs` while the direct Platform endpoint still returned the expected tenant-gated `400`. | Developer | +| 2026-03-10 | Added explicit `/api/v2/evidence*` mappings to both router configs, extended router resolution/dispatch tests, and verified `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj` passes `275/275`. | Developer | +| 2026-03-10 | Restarted `router-gateway`, confirmed `https://stella-ops.local/api/v2/evidence/packs` now returns `401` instead of `404`, and reran `src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs` with `failedActionCount=0` and `runtimeIssueCount=0`. | QA | + +## Decisions & Risks +- Decision: keep `/api/v2/evidence*` as an explicit Platform-owned route alongside the other aggregated v2 read models instead of relying on the generic `^/api/v2/{service}` fallback, because `evidence` is not a standalone frontdoor host in local compose. +- Risk: other aggregated v2 surfaces could still be missing from the explicit route list. +- Mitigation: treat every new frontdoor `404` from a v2 read model as a route-table regression first and extend the explicit mapping test list when confirmed. + +## Next Checkpoints +- Rebuild/restart `router-gateway` with the updated route table. +- Rerun the affected Mission Control and environment posture Playwright sweeps. diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md index 649fbf28f..cd18f4814 100644 --- a/docs/modules/router/architecture.md +++ b/docs/modules/router/architecture.md @@ -91,6 +91,8 @@ Regex microservice routes that own a root prefix must use a segment boundary whe Browser-facing compatibility prefixes that exist only at the frontdoor must also use segment boundaries and strip that prefix before dispatching to the target microservice. Local compose keeps `/doctor/api/v1/doctor/*` and `/scheduler/api/v1/scheduler/*` for the shell, but the route table translates `^/doctor(?=/|$)(.*)` and `^/scheduler(?=/|$)(.*)` to `http://doctor.stella-ops.local$1` and `http://scheduler.stella-ops.local$1` so the backend still receives its canonical `/api/v1//*` path without stealing SPA chunks such as `doctor.routes-*.js`. +Platform-owned v2 read models must also stay explicit ahead of the generic `^/api/v2/{service}` matcher. Local compose currently pins `/api/v2/context*`, `/api/v2/releases*`, `/api/v2/security*`, `/api/v2/topology*`, `/api/v2/evidence*`, and `/api/v2/integrations*` to `platform` because those endpoints are aggregated read models rather than standalone microservice hosts. + ### Pipeline Order System paths (`/health`, `/metrics`, `/openapi.*`) bypass the route table entirely. The dispatch middleware runs before the microservice pipeline: diff --git a/src/Router/StellaOps.Gateway.WebService/TASKS.md b/src/Router/StellaOps.Gateway.WebService/TASKS.md index d126bc4b4..ab8e7ce13 100644 --- a/src/Router/StellaOps.Gateway.WebService/TASKS.md +++ b/src/Router/StellaOps.Gateway.WebService/TASKS.md @@ -14,3 +14,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | RGH-03 | DONE | 2026-03-05: Aligned `/api/v1/search*` and `/api/v1/advisory-ai*` route translations to AdvisoryAI `/v1/*`, added compose/runtime parity safeguards, and verified setup smoke coverage. | | RGH-04 | DONE | 2026-03-10: Tightened frontdoor route boundaries for `/policy`, restored explicit platform ownership for `/api/v1/aoc*` and `/api/v1/administration*`, and stripped `/doctor`/`/scheduler` shell prefixes before microservice dispatch. | | RGH-05 | DONE | 2026-03-10: Added segment boundaries to `/doctor` and `/scheduler` frontdoor prefixes so lazy web chunks stay on static hosting while compatibility API prefixes still dispatch to the correct microservice. | +| RGH-06 | DONE | 2026-03-10: Restored explicit platform ownership for `/api/v2/evidence*` so environment posture and mission-control evidence read models no longer fall through to the generic v2 service resolver. | diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 4a10a292b..3372f4e44 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -117,6 +117,7 @@ { "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" }, { "Type": "Microservice", "Path": "^/api/v2/security(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/security$1" }, { "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" }, + { "Type": "Microservice", "Path": "^/api/v2/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/evidence$1" }, { "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" }, { "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" }, diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs index 193e9c6b1..2fd743f4e 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs @@ -18,6 +18,7 @@ public sealed class GatewayRouteSearchMappingsTests ("^/api/v2/releases(.*)", "http://platform.stella-ops.local/api/v2/releases$1", "Microservice", true), ("^/api/v2/security(.*)", "http://platform.stella-ops.local/api/v2/security$1", "Microservice", true), ("^/api/v2/topology(.*)", "http://platform.stella-ops.local/api/v2/topology$1", "Microservice", true), + ("^/api/v2/evidence(.*)", "http://platform.stella-ops.local/api/v2/evidence$1", "Microservice", true), ("^/api/v2/integrations(.*)", "http://platform.stella-ops.local/api/v2/integrations$1", "Microservice", true), ("^/scheduler(?=/|$)(.*)", "http://scheduler.stella-ops.local$1", "Microservice", true), ("^/doctor(?=/|$)(.*)", "http://doctor.stella-ops.local$1", "Microservice", true), diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs index 61114bce9..3d4f7a11f 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs @@ -390,6 +390,53 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string); } + [Fact] + public async Task InvokeAsync_SpecificPlatformV2EvidenceRoute_PrefersPlatformOverGenericCatchAll() + { + var resolver = new StellaOpsRouteResolver( + [ + new StellaOpsRoute + { + Type = StellaOpsRouteType.Microservice, + Path = @"^/api/v2/evidence(.*)", + IsRegex = true, + TranslatesTo = "http://platform.stella-ops.local/api/v2/evidence$1" + }, + new StellaOpsRoute + { + Type = StellaOpsRouteType.Microservice, + Path = @"^/api/v2/([^/]+)(.*)", + IsRegex = true, + TranslatesTo = "http://$1.stella-ops.local/api/v2/$1$2" + } + ]); + + var httpClientFactory = new Mock(); + httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny())).Returns(new HttpClient()); + + var nextCalled = false; + var middleware = new RouteDispatchMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + resolver, + httpClientFactory.Object, + NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v2/evidence/packs"; + + await middleware.InvokeAsync(context); + + Assert.True(nextCalled); + Assert.Equal( + "platform", + context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string); + Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath)); + } + [Theory] [InlineData("/doctor/api/v1/doctor/checks", @"^/doctor(?=/|$)(.*)", "http://doctor.stella-ops.local$1", "doctor", "/api/v1/doctor/checks")] [InlineData("/scheduler/api/v1/scheduler/runs", @"^/scheduler(?=/|$)(.*)", "http://scheduler.stella-ops.local$1", "scheduler", "/api/v1/scheduler/runs")] diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs index f97796e47..7eb1a144e 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs @@ -152,6 +152,29 @@ public sealed class StellaOpsRouteResolverTests Assert.Equal(expectedCapture, result.RegexMatch!.Groups[1].Value); } + [Fact] + public void Resolve_SpecificPlatformV2EvidenceRoute_BeatsGenericCatchAll() + { + var resolver = new StellaOpsRouteResolver( + [ + MakeRoute( + @"^/api/v2/evidence(.*)", + isRegex: true, + translatesTo: "http://platform.stella-ops.local/api/v2/evidence$1"), + MakeRoute( + @"^/api/v2/([^/]+)(.*)", + isRegex: true, + translatesTo: "http://$1.stella-ops.local/api/v2/$1$2") + ]); + + var result = resolver.Resolve(new PathString("/api/v2/evidence/packs")); + + Assert.NotNull(result.Route); + Assert.Equal(@"^/api/v2/evidence(.*)", result.Route!.Path); + Assert.NotNull(result.RegexMatch); + Assert.Equal("/packs", result.RegexMatch!.Groups[1].Value); + } + [Fact] public void Resolve_NoMatch_ReturnsNull() {