From d881fff38766d09f1413f86fb4e5ff2d0b59417c Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 12:47:51 +0200 Subject: [PATCH] Segment-bound doctor and scheduler frontdoor chunks --- devops/compose/README.md | 5 +- devops/compose/router-gateway-local.json | 4 +- ...r_segment_bound_scheduler_doctor_chunks.md | 59 +++++++++++++++++++ docs/modules/router/architecture.md | 2 +- .../StellaOps.Gateway.WebService/TASKS.md | 1 + .../appsettings.json | 4 +- .../GatewayRouteSearchMappingsTests.cs | 35 +++++++++-- ...outeDispatchMiddlewareMicroserviceTests.cs | 4 +- .../Routing/StellaOpsRouteResolverTests.cs | 39 ++++++++++++ 9 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md diff --git a/devops/compose/README.md b/devops/compose/README.md index e6718553b..390ea59fb 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -136,8 +136,9 @@ 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/*` strip the frontdoor prefix before dispatch so the target services still -receive their canonical `/api/v1/doctor/*` and `/api/v1/scheduler/*` paths. +`/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 773443a0e..8ec1286b7 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -105,8 +105,8 @@ { "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" }, { "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" }, { "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" }, - { "Type": "Microservice", "Path": "^/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, - { "Type": "Microservice", "Path": "^/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, + { "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, + { "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" }, { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" }, { "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" }, diff --git a/docs/implplan/SPRINT_20260310_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md b/docs/implplan/SPRINT_20260310_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md new file mode 100644 index 000000000..adc7c97de --- /dev/null +++ b/docs/implplan/SPRINT_20260310_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md @@ -0,0 +1,59 @@ +# Sprint 20260310-021 - Router Frontdoor Segment Bound Scheduler Doctor Chunks + +## Topic & Scope +- Repair the remaining live frontdoor regressions where newly-added `/doctor` and `/scheduler` compatibility prefixes capture Angular lazy chunks instead of only owning the actual URL segment. +- Revalidate the scratch stack with Playwright after the gateway config change so canonical route coverage can move past router dispatch defects. +- 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_021_Router_frontdoor_segment_bound_scheduler_doctor_chunks.md`. +- Expected evidence: focused router tests, live chunk/backend probes, and a rerun of the canonical Playwright sweep. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260310_020_Router_frontdoor_route_boundary_and_service_prefix_repair.md`, which introduced the shell compatibility prefixes that exposed the lazy-chunk collision. +- Safe parallelism: stay inside router config/tests/docs while the frontend route layer remains untouched. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Router/AGENTS.md` +- `src/Router/StellaOps.Gateway.WebService/AGENTS.md` +- `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/AGENTS.md` +- `docs/modules/router/architecture.md` + +## Delivery Tracker + +### ROUTER-SEGMENT-001 - Segment-bound doctor and scheduler frontdoor prefixes +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Change the frontdoor `/doctor` and `/scheduler` regexes so they match only the route segment and no longer own static chunk filenames such as `doctor.routes-*.js` and `scheduler-ops.routes-*.js`. + +Completion criteria: +- [x] Gateway config uses segment-bound regexes for both prefixes. +- [x] Focused route-table tests lock the exact regexes. +- [x] Resolver tests prove static chunks still resolve to SPA/static files. + +### ROUTER-SEGMENT-002 - Reverify the live scratch frontdoor and Playwright sweep +Status: DONE +Dependency: ROUTER-SEGMENT-001 +Owners: QA +Task description: +- Restart `router-gateway`, verify the affected chunk URLs and API prefixes live, and rerun the canonical Playwright route sweep on `https://stella-ops.local`. + +Completion criteria: +- [x] Live chunk requests for doctor and scheduler return frontend assets instead of `503`. +- [x] Live backend requests through `/doctor/*` and `/scheduler/*` still reach the authenticated services. +- [x] Canonical Playwright sweep no longer fails on `/ops/operations/doctor` or `/ops/operations/scheduler`. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after the canonical Playwright sweep isolated two remaining failures. Root cause: the new `/doctor` and `/scheduler` frontdoor regexes were broad enough to capture lazy chunk files and return `503` on dynamic imports. | Developer | +| 2026-03-10 | Tightened the doctor and scheduler regexes to `(?=/|$)` segment-bound forms, added route-table/resolver/middleware coverage, and reran the focused router test project successfully (`273/273`). | Developer | +| 2026-03-10 | Recycled `router-gateway`, confirmed doctor/scheduler API prefixes still return authenticated backend responses, confirmed the previously failing lazy chunk URLs now return `200 text/javascript`, and reran the canonical Playwright sweep cleanly at `111/111` passed. | QA | + +## Decisions & Risks +- Decision: root-prefix compatibility routes must follow the same segment-bound rule as `/policy` whenever the SPA emits chunk names with a shared textual prefix. +- Risk: if additional frontdoor root prefixes are added without the boundary rule, similar lazy-chunk outages can reappear. Mitigation: keep resolver tests for static-chunk collisions alongside route-table changes. + +## Next Checkpoints +- 2026-03-10: rerun the live canonical sweep and confirm whether any non-router failures remain. diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md index ad434de05..649fbf28f 100644 --- a/docs/modules/router/architecture.md +++ b/docs/modules/router/architecture.md @@ -89,7 +89,7 @@ Reverse proxy is reserved for external/bootstrap surfaces such as OIDC browser f Regex microservice routes that own a root prefix must use a segment boundary when the same prefix can appear in static asset filenames. The local frontdoor uses `^/policy(?=/|$)(.*)` rather than `^/policy(.*)` so Angular chunks such as `/policy-decisioning.routes-*.js` stay on the SPA/static path instead of being misrouted to the Policy service. -Browser-facing compatibility prefixes that exist only at the frontdoor must 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 them to `http://doctor.stella-ops.local$1` and `http://scheduler.stella-ops.local$1` so the backend still receives its canonical `/api/v1//*` path. +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`. ### Pipeline Order diff --git a/src/Router/StellaOps.Gateway.WebService/TASKS.md b/src/Router/StellaOps.Gateway.WebService/TASKS.md index 27a977481..d126bc4b4 100644 --- a/src/Router/StellaOps.Gateway.WebService/TASKS.md +++ b/src/Router/StellaOps.Gateway.WebService/TASKS.md @@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | RGH-02 | DONE | 2026-03-04: Expanded approved auth passthrough prefixes (`/authority`, `/doctor`, `/api`) to unblock authenticated gateway routes used by Audit Log UI E2E. | | 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. | diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 3630dbe56..4a10a292b 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -133,8 +133,8 @@ { "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" }, { "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" }, { "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" }, - { "Type": "Microservice", "Path": "^/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, - { "Type": "Microservice", "Path": "^/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, + { "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, + { "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" }, { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" }, { "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" }, 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 dda955de4..193e9c6b1 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs @@ -19,8 +19,8 @@ public sealed class GatewayRouteSearchMappingsTests ("^/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/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), + ("^/scheduler(?=/|$)(.*)", "http://scheduler.stella-ops.local$1", "Microservice", true), + ("^/doctor(?=/|$)(.*)", "http://doctor.stella-ops.local$1", "Microservice", true), ("^/api/jobengine(.*)", "http://orchestrator.stella-ops.local/api/jobengine$1", "Microservice", true), ("^/api/scheduler(.*)", "http://scheduler.stella-ops.local/api/scheduler$1", "Microservice", true) ]; @@ -90,7 +90,7 @@ public sealed class GatewayRouteSearchMappingsTests [Theory] [MemberData(nameof(RouteConfigPaths))] - public void RouteTable_UsesSegmentBoundaryForPolicyRootRoute(string configRelativePath) + public void RouteTable_UsesSegmentBoundariesForFrontdoorRootPrefixes(string configRelativePath) { var repoRoot = FindRepositoryRoot(); var configPath = Path.Combine(repoRoot, configRelativePath.Replace('/', Path.DirectorySeparatorChar)); @@ -113,9 +113,32 @@ public sealed class GatewayRouteSearchMappingsTests route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean())) .ToList(); - var route = routes.SingleOrDefault(candidate => string.Equals(candidate.TranslatesTo, "http://policy-gateway.stella-ops.local/policy$1", StringComparison.Ordinal)); - Assert.True(route is not null, $"Missing policy root route in {configRelativePath}."); - Assert.Equal("^/policy(?=/|$)(.*)", route!.Path); + AssertSegmentBoundRoute( + routes, + "http://policy-gateway.stella-ops.local/policy$1", + "^/policy(?=/|$)(.*)", + configRelativePath); + AssertSegmentBoundRoute( + routes, + "http://scheduler.stella-ops.local$1", + "^/scheduler(?=/|$)(.*)", + configRelativePath); + AssertSegmentBoundRoute( + routes, + "http://doctor.stella-ops.local$1", + "^/doctor(?=/|$)(.*)", + configRelativePath); + } + + private static void AssertSegmentBoundRoute( + IEnumerable routes, + string expectedTarget, + string expectedPath, + string configRelativePath) + { + var route = routes.SingleOrDefault(candidate => string.Equals(candidate.TranslatesTo, expectedTarget, StringComparison.Ordinal)); + Assert.True(route is not null, $"Missing route for {expectedTarget} in {configRelativePath}."); + Assert.Equal(expectedPath, route!.Path); } [Theory] 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 be7749672..61114bce9 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs @@ -391,8 +391,8 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests } [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")] + [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")] public async Task InvokeAsync_ServiceRootPrefixRoute_StripsFrontdoorPrefixBeforeDispatch( string requestPath, string routePath, 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 a309e358f..f97796e47 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Routing/StellaOpsRouteResolverTests.cs @@ -113,6 +113,45 @@ public sealed class StellaOpsRouteResolverTests Assert.Equal("/overview", policyChildResult.RegexMatch!.Groups[1].Value); } + [Theory] + [InlineData(@"^/doctor(?=/|$)(.*)", "http://doctor.stella-ops.local$1", "/doctor.routes-JHTKGEQ6.js", StellaOpsRouteType.StaticFiles, null)] + [InlineData(@"^/doctor(?=/|$)(.*)", "http://doctor.stella-ops.local$1", "/doctor/api/v1/doctor/checks", StellaOpsRouteType.Microservice, "/api/v1/doctor/checks")] + [InlineData(@"^/scheduler(?=/|$)(.*)", "http://scheduler.stella-ops.local$1", "/scheduler-ops.routes-5SHXET2O.js", StellaOpsRouteType.StaticFiles, null)] + [InlineData(@"^/scheduler(?=/|$)(.*)", "http://scheduler.stella-ops.local$1", "/scheduler/api/v1/scheduler/runs", StellaOpsRouteType.Microservice, "/api/v1/scheduler/runs")] + public void Resolve_SegmentBoundFrontdoorServiceRoots_DoNotCaptureStaticChunks( + string routePath, + string translatesTo, + string requestPath, + StellaOpsRouteType expectedType, + string? expectedCapture) + { + var resolver = new StellaOpsRouteResolver( + [ + MakeRoute( + routePath, + isRegex: true, + translatesTo: translatesTo), + MakeRoute( + "/", + type: StellaOpsRouteType.StaticFiles, + translatesTo: "/app/wwwroot") + ]); + + var result = resolver.Resolve(new PathString(requestPath)); + + Assert.NotNull(result.Route); + Assert.Equal(expectedType, result.Route!.Type); + + if (expectedCapture is null) + { + Assert.Null(result.RegexMatch); + return; + } + + Assert.NotNull(result.RegexMatch); + Assert.Equal(expectedCapture, result.RegexMatch!.Groups[1].Value); + } + [Fact] public void Resolve_NoMatch_ReturnsNull() {