From afa23fc504c8c82b3aaf694e37a0df9d9f6e087c Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 04:26:54 +0200 Subject: [PATCH] Fix router ASP.NET request body binding --- ...se_insensitive_forwarded_header_binding.md | 77 +++++++++++++++ docs/modules/router/architecture.md | 1 + .../AspNetRouterRequestDispatcher.cs | 58 +++++++++-- .../TASKS.md | 1 + .../AspNetRouterRequestDispatcherTests.cs | 97 +++++++++++++++++++ .../StellaOps.Router.AspNet.Tests/TASKS.md | 1 + 6 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md diff --git a/docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md b/docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md new file mode 100644 index 000000000..bc420e481 --- /dev/null +++ b/docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md @@ -0,0 +1,77 @@ +# Sprint 20260307-011 - Router Case-Insensitive Forwarded Header Binding + +## Topic & Scope +- Remove the live `Failed to persist global context preferences.` degradation that still occurs after Topology operator actions on `https://stella-ops.local`. +- Fix the Router ASP.NET bridge so forwarded HTTP headers remain case-insensitive after request-frame transport round-trips. +- Add focused Router regression coverage for lowercase `content-type` on JSON request dispatch and verify the repaired live user flow with Playwright. +- Working directory: `src/Router/__Libraries/StellaOps.Microservice.AspNetCore`. +- Expected evidence: targeted Router tests, rebuilt `platform` container on the live stack, and Playwright artifacts for the affected topology actions. + +## Dependencies & Concurrency +- Upstream dependency: `docs/implplan/SPRINT_20260307_010_FE_context_preferences_canonical_payload.md` aligned the Web payload with the Platform v2 contract and reduced the live failure from gateway `404` to service-side `400`. +- Safe parallelism: stay inside the Router ASP.NET bridge, its focused tests, Router docs, and sprint/task-board updates. Do not edit unrelated Web navigation or settings work from other agents. +- Deployment dependency: the live fix only requires rebuilding the `platform` image because the failing bridge code executes inside the Platform service process. + +## Documentation Prerequisites +- `docs/modules/router/architecture.md` +- `src/Router/AGENTS.md` +- `src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AGENTS.md` + +## Delivery Tracker + +### ROUTER-HDR-001 - Reproduce the live context persistence failure after the frontend contract fix +Status: DONE +Dependency: none +Owners: QA +Task description: +- Replay the Topology environment detail operator actions with live Playwright after the Web payload was corrected. +- Capture the remaining request/response behavior and determine whether the fault still sits in the gateway/bridge path or in Platform business logic. + +Completion criteria: +- [x] Live Playwright reproduces the degraded banner after Topology operator actions. +- [x] Captured requests show the canonical payload emitted by Web. +- [x] Evidence isolates the remaining failure to Router-dispatched service execution rather than route discovery. + +### ROUTER-HDR-002 - Restore case-insensitive forwarded header handling in the ASP.NET bridge +Status: DONE +Dependency: ROUTER-HDR-001 +Owners: Developer +Task description: +- Update `AspNetRouterRequestDispatcher` so forwarded headers behave according to HTTP semantics after frame transport round-trips. +- Add a focused regression that round-trips a lowercase `content-type` header and verifies JSON request metadata survives dispatch. +- Ensure minimal-API request-body detection is populated so JSON body binding continues to work after Router frame dispatch. + +Completion criteria: +- [x] Lowercase forwarded `content-type` reaches dispatched ASP.NET endpoints. +- [x] The regression fails on the broken behavior and passes with the fix. +- [x] The change remains scoped to Router bridge semantics without unrelated transport rewrites. + +### ROUTER-HDR-003 - Rebuild Platform and verify the live topology operator actions +Status: DONE +Dependency: ROUTER-HDR-002 +Owners: QA +Task description: +- Rebuild the `stellaops/platform:dev` image with the Router bridge fix, recreate only the Platform container, and replay the affected operator actions using Playwright. +- Confirm the degraded banner disappears and the downstream routes still render with the persisted environment context. + +Completion criteria: +- [x] Live Playwright confirms the degraded banner is gone for `Open Targets`, `Open Agents`, `Open Runs`, and `Open Security Triage`. +- [x] `PUT /api/v2/context/preferences` succeeds on the live stack. +- [x] The downstream routes remain functional after context persistence updates. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-07 | Sprint created after live Playwright replay on `https://stella-ops.local/setup/topology/environments/dev/posture` still showed `EVENTS: DEGRADED Failed to persist global context preferences.` even after the Web payload was reduced to canonical v2 fields. | QA | +| 2026-03-07 | Rebuilt only `platform` with the latest Router transport changes and replayed the live flow. The request path moved from gateway `404` to routed `400`, proving route registration was fixed and the remaining failure was inside dispatched request execution. | QA | +| 2026-03-07 | Live capture showed `PUT /api/v2/context/preferences` carrying canonical JSON while Router logs confirmed dispatch to `platform/1.0.0-alpha1`; the remaining suspicion is case-sensitive header rehydration dropping HTTP/2 lowercase `content-type` before ASP.NET body binding. | QA | +| 2026-03-07 | Added focused Router bridge regressions, including a real minimal-API JSON body-binding replay. The stronger test reproduced the same `400` until the dispatcher restored case-insensitive header lookup and populated ASP.NET request-body detection for frame-dispatched POST/PUT/PATCH requests. | Developer | +| 2026-03-07 | Rebuilt and recreated only `platform`, then replayed the four Topology operator actions with live Playwright. `PUT /api/v2/context/preferences` now returns `200` and the UI remains on `EVENTS: CONNECTED` for Targets, Agents, Runs, and Security Triage. | QA | + +## Decisions & Risks +- Decision: fix the Router bridge at the header rehydration layer instead of widening Platform contracts or adding UI-side workarounds. +- Decision: include explicit ASP.NET request-body detection in the bridge because header casing alone was insufficient for minimal-API JSON binding after request-frame dispatch. +- Residual risk: other services using the same bridge should still be covered by broader Playwright/API sweeps because this fix addresses the common body-binding path but does not validate every endpoint surface. + +## Next Checkpoints +- 2026-03-07: continue live Playwright QA sweeps now that Topology operator actions no longer degrade global context persistence. diff --git a/docs/modules/router/architecture.md b/docs/modules/router/architecture.md index 7d620bab0..fab0a1a03 100644 --- a/docs/modules/router/architecture.md +++ b/docs/modules/router/architecture.md @@ -17,6 +17,7 @@ Rollout policy: `docs/operations/multi-tenant-rollout-and-compatibility.md` - Microservices communicate with the Gateway using binary transports (TCP, TLS, UDP, RabbitMQ) - HTTP is not used for internal microservice-to-gateway traffic - Request/response bodies are opaque to the router (raw bytes/streams) +- Forwarded HTTP headers remain case-insensitive across Router frame transport and ASP.NET bridge dispatch; lowercase HTTP/2 names such as `content-type` must be preserved for JSON-bound endpoints, and the ASP.NET bridge must mark POST/PUT/PATCH requests as body-capable so minimal-API JSON binding survives frame dispatch ### Transport Architecture diff --git a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs index a1f76ca6b..5108c78b2 100644 --- a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs +++ b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs @@ -176,6 +176,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch httpRequest.QueryString = queryString; httpRequest.Scheme = "https"; // Router always uses secure transport conceptually httpRequest.Host = new HostString(_options.ServiceName); + httpRequest.Protocol = "HTTP/2"; // Copy headers foreach (var (key, value) in request.Headers) @@ -189,13 +190,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch httpRequest.Body = new MemoryStream(request.Payload.ToArray()); httpRequest.ContentLength = request.Payload.Length; - // Try to set Content-Type from headers - if (request.Headers.TryGetValue("Content-Type", out var contentType)) + // HTTP header names are case-insensitive; browsers often emit lowercase + // names over HTTP/2, so read back from the copied header collection. + if (httpRequest.Headers.TryGetValue("Content-Type", out var contentType)) { - httpRequest.ContentType = contentType; + httpRequest.ContentType = contentType.ToString(); } } + httpContext.Features.Set(new RouterRequestBodyDetectionFeature + { + CanHaveBody = request.Payload.Length > 0 + || HttpMethods.IsPost(request.Method) + || HttpMethods.IsPut(request.Method) + || HttpMethods.IsPatch(request.Method) + }); + // Populate identity from StellaOps headers var identityResult = PopulateIdentity(httpContext, request.Headers); if (identityResult == IdentityPopulationResult.Rejected) @@ -253,14 +263,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch { failureReason = null; - if (!headers.TryGetValue(IdentityEnvelopeHeader, out var encodedEnvelope) || + if (!TryGetHeaderValue(headers, IdentityEnvelopeHeader, out var encodedEnvelope) || string.IsNullOrWhiteSpace(encodedEnvelope)) { failureReason = "envelope header missing"; return false; } - if (!headers.TryGetValue(IdentityEnvelopeSignatureHeader, out var encodedSignature) || + if (!TryGetHeaderValue(headers, IdentityEnvelopeSignatureHeader, out var encodedSignature) || string.IsNullOrWhiteSpace(encodedSignature)) { failureReason = "envelope signature header missing"; @@ -357,26 +367,26 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch var claims = new List(); // Actor (subject/user ID) - if (headers.TryGetValue(ActorHeader, out var actor) && !string.IsNullOrEmpty(actor)) + if (TryGetHeaderValue(headers, ActorHeader, out var actor) && !string.IsNullOrEmpty(actor)) { claims.Add(new Claim(ClaimTypes.NameIdentifier, actor)); claims.Add(new Claim("sub", actor)); } // Tenant - if (headers.TryGetValue(TenantHeader, out var tenant) && !string.IsNullOrEmpty(tenant)) + if (TryGetHeaderValue(headers, TenantHeader, out var tenant) && !string.IsNullOrEmpty(tenant)) { claims.Add(new Claim("tenant", tenant)); } // Session - if (headers.TryGetValue(SessionHeader, out var session) && !string.IsNullOrEmpty(session)) + if (TryGetHeaderValue(headers, SessionHeader, out var session) && !string.IsNullOrEmpty(session)) { claims.Add(new Claim("session", session)); } // Scopes (space-separated) - if (headers.TryGetValue(ScopesHeader, out var scopes) && !string.IsNullOrEmpty(scopes)) + if (TryGetHeaderValue(headers, ScopesHeader, out var scopes) && !string.IsNullOrEmpty(scopes)) { foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { @@ -385,7 +395,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch } // Roles (space-separated) - if (headers.TryGetValue(RolesHeader, out var roles) && !string.IsNullOrEmpty(roles)) + if (TryGetHeaderValue(headers, RolesHeader, out var roles) && !string.IsNullOrEmpty(roles)) { foreach (var role in roles.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { @@ -400,6 +410,29 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch } } + private static bool TryGetHeaderValue( + IReadOnlyDictionary headers, + string name, + out string value) + { + if (headers.TryGetValue(name, out value!)) + { + return true; + } + + foreach (var (key, candidate) in headers) + { + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) + { + value = candidate; + return true; + } + } + + value = string.Empty; + return false; + } + #pragma warning disable CS1998 private async Task MatchEndpointAsync(HttpContext httpContext) { @@ -768,6 +801,11 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch public RouteValueDictionary RouteValues { get; set; } = new(); } + private sealed class RouterRequestBodyDetectionFeature : IHttpRequestBodyDetectionFeature + { + public bool CanHaveBody { get; init; } + } + /// /// Populates the IStellaOpsTenantAccessor. This mimics what StellaOpsTenantMiddleware does /// in the normal HTTP pipeline: resolves the tenant from claims/headers and sets it diff --git a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TASKS.md b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TASKS.md index cb043cd47..904ae42c5 100644 --- a/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TASKS.md +++ b/src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0388-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Microservice.AspNetCore. | | AUDIT-0388-A | TODO | Revalidated 2026-01-07 (open findings). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| HDR-CASE-01 | DONE | Sprint `docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md`: restored case-insensitive forwarded header handling in the ASP.NET bridge so lowercase HTTP/2 `content-type` reaches JSON-bound endpoints. | diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs index 6326c94b9..6477e340e 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/AspNetRouterRequestDispatcherTests.cs @@ -1,4 +1,5 @@ using System.Text; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; @@ -103,6 +104,100 @@ public sealed class AspNetRouterRequestDispatcherTests Assert.Equal("alice", Encoding.UTF8.GetString(response.Payload.ToArray())); } + [Fact] + public async Task DispatchAsync_RoundTrippedLowercaseContentType_PreservesJsonRequestMetadata() + { + var routeEndpoint = new RouteEndpoint( + async context => + { + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + var isJson = string.Equals(context.Request.ContentType, "application/json", StringComparison.OrdinalIgnoreCase); + + context.Response.StatusCode = isJson && body == "{\"name\":\"stella\"}" + ? StatusCodes.Status200OK + : StatusCodes.Status400BadRequest; + }, + RoutePatternFactory.Parse("/api/v1/context/preferences"), + order: 0, + new EndpointMetadataCollection(new HttpMethodMetadata(["PUT"])), + displayName: "Preferences"); + + var dispatcher = CreateDispatcher( + routeEndpoint, + new StellaRouterBridgeOptions + { + ServiceName = "platform", + Version = "1.0.0-alpha1", + Region = "local", + AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced + }); + + var transportFrame = FrameConverter.ToFrame(new RequestFrame + { + RequestId = "req-3", + Method = "PUT", + Path = "/api/v1/context/preferences", + Headers = new Dictionary + { + ["content-type"] = "application/json" + }, + Payload = Encoding.UTF8.GetBytes("{\"name\":\"stella\"}") + }); + + var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame); + Assert.NotNull(roundTrippedRequest); + + var response = await dispatcher.DispatchAsync(roundTrippedRequest!); + + Assert.Equal(StatusCodes.Status200OK, response.StatusCode); + } + + [Fact] + public async Task DispatchAsync_RoundTrippedLowercaseContentType_BindsMinimalApiJsonBody() + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + app.MapPut("/api/v1/context/preferences", (PreferencesBody body) => Results.Ok(body)); + + var endpointRouteBuilder = (IEndpointRouteBuilder)app; + var endpointDataSource = new StaticEndpointDataSource( + endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray()); + var dispatcher = new AspNetRouterRequestDispatcher( + app.Services, + endpointDataSource, + new StellaRouterBridgeOptions + { + ServiceName = "platform", + Version = "1.0.0-alpha1", + Region = "local", + AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced + }, + NullLogger.Instance); + + var transportFrame = FrameConverter.ToFrame(new RequestFrame + { + RequestId = "req-4", + Method = "PUT", + Path = "/api/v1/context/preferences", + Headers = new Dictionary + { + ["content-type"] = "application/json" + }, + Payload = Encoding.UTF8.GetBytes("{\"regions\":[],\"environments\":[\"dev\"],\"timeWindow\":\"24h\"}") + }); + + var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame); + Assert.NotNull(roundTrippedRequest); + + var response = await dispatcher.DispatchAsync(roundTrippedRequest!); + var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray()); + + Assert.Equal(StatusCodes.Status200OK, response.StatusCode); + Assert.Contains("\"environments\":[\"dev\"]", responseBody, StringComparison.Ordinal); + Assert.Contains("\"timeWindow\":\"24h\"", responseBody, StringComparison.Ordinal); + } + private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options) { var services = new ServiceCollection(); @@ -130,4 +225,6 @@ public sealed class AspNetRouterRequestDispatcherTests { public override Task SelectAsync(HttpContext httpContext, CandidateSet candidates) => Task.CompletedTask; } + + private sealed record PreferencesBody(string[] Regions, string[] Environments, string TimeWindow); } diff --git a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md index 1d87fb2da..fe9ac72e4 100644 --- a/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md +++ b/src/Router/__Tests/StellaOps.Router.AspNet.Tests/TASKS.md @@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | RVM-02 | DONE | Added `AddRouterMicroservice()` DI tests for disabled mode, gateway validation, TCP registration, and Valkey messaging options wiring via plugin-based transport activation; extended ASP.NET discovery tests for OpenAPI metadata/schema extraction and typed response-schema selection fallback. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| HDR-CASE-01 | DONE | Added the lowercase `content-type` request-frame regression so JSON body dispatch survives Router frame round-trips. |