Repair router frontdoor convergence and live route contracts

This commit is contained in:
master
2026-03-09 19:09:19 +02:00
parent 49d1c57597
commit bf937c9395
25 changed files with 740 additions and 61 deletions

View File

@@ -0,0 +1,78 @@
# Sprint 20260309-008 - Router Live Messaging Heartbeat Contract Repair
## Topic & Scope
- Repair the live frontdoor `503` cluster triggered after the full scratch rebuild, where healthy services are marked degraded or unhealthy because gateway heartbeat thresholds undercut the messaging transport's missed-notification fallback.
- Preserve the Valkey push-first CPU fix while ensuring a missed wake-up cannot stall queue consumption long enough to trip false gateway health failures.
- Rebuild and redeploy the affected router slice, then rerun the authenticated live Playwright sweep to confirm the shared `503` backlog collapses before moving on to page-specific defects.
- Working directory: `src/Router`.
- Allowed coordination edits: `docs/modules/router/architecture.md`, `docs/implplan/SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md`.
- Expected evidence: focused router unit tests, rebuilt router image, redeployed gateway, refreshed live Playwright sweep artifact.
## Dependencies & Concurrency
- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the rebuilt baseline and `SPRINT_20260309_003_Router_live_frontdoor_contract_repair.md` for the already-restored frontdoor bindings.
- Safe parallelism: avoid the unrelated search and component-revival slices already landed by other agents; this sprint is limited to router messaging wake-up behavior, gateway health threshold policy, and live verification artifacts.
## Documentation Prerequisites
- `AGENTS.md`
- `src/Router/AGENTS.md`
- `src/Router/StellaOps.Gateway.WebService/AGENTS.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/router/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### ROUTER-LIVE-008-001 - Bound the messaging wake-up fallback to heartbeat cadence
Status: DONE
Dependency: none
Owners: Developer, QA
Task description:
- Replace the fixed 30-second notifiable-queue fallback with a heartbeat-aware safety-net timeout so a missed Valkey pub/sub wake-up does not leave the gateway or microservices asleep long enough to look dead.
- Keep the transport push-first and low-CPU: the fallback exists only for missed notifications, not as a return to aggressive polling.
Completion criteria:
- [x] Messaging queue waits derive their safety-net timeout from the configured heartbeat interval instead of a fixed 30-second constant.
- [x] Focused router tests cover the timeout calculation contract.
- [x] The transport remains push-first for notifiable queues.
### ROUTER-LIVE-008-002 - Harden gateway health thresholds against heartbeat jitter
Status: DONE
Dependency: ROUTER-LIVE-008-001
Owners: Developer, QA
Task description:
- Normalize gateway degraded/stale thresholds against the configured messaging heartbeat interval so the live gateway cannot mark healthy instances degraded or unhealthy earlier than the transport contract allows.
- Prefer a durable source-level policy over a compose-only tweak so the next scratch rebuild preserves the fix.
Completion criteria:
- [x] Gateway health options are normalized to a minimum of 2x/3x the configured messaging heartbeat interval for degraded/stale transitions.
- [x] Focused router tests lock the health-threshold normalization behavior.
- [x] The router architecture dossier documents the heartbeat-to-health contract.
### ROUTER-LIVE-008-003 - Rebuild, redeploy, and verify the live frontdoor
Status: DONE
Dependency: ROUTER-LIVE-008-002
Owners: QA
Task description:
- Rebuild and redeploy the router slice, rerun the authenticated live sweep, and record whether the shared `503` cluster is removed or narrowed for the next iteration.
Completion criteria:
- [x] Router artifacts are rebuilt and redeployed on the live compose stack.
- [x] The authenticated live Playwright sweep is rerun from the rebuilt stack.
- [x] Remaining failures are recorded with current evidence if any survive.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-09 | Sprint created after the rebuilt live stack showed shared gateway `503` failures caused by heartbeat health flapping rather than page-local defects. | Developer |
| 2026-03-09 | Updated messaging wait fallback to use heartbeat-derived safety-net timeouts, normalized gateway degraded/stale thresholds against messaging heartbeat cadence, and added focused router tests for both contracts. | Developer |
| 2026-03-09 | Rebuilt the full image set, redeployed the live compose stack, then reran authenticated Playwright sweeps. The first post-redeploy sweep showed transient cross-service `404` convergence misses; the second consecutive sweep completed `111/111` against `src/Web/StellaOps.Web/output/playwright/live-frontdoor-canonical-route-sweep.json`. | QA |
## Decisions & Risks
- Decision: fix the router transport/gateway heartbeat contract in source instead of only loosening compose thresholds, because scratch rebuilds must preserve the runtime behavior.
- Decision: treat the transient post-redeploy `404` cluster as the same convergence class as earlier health flapping until proven otherwise; verify with consecutive authenticated Playwright sweeps before opening page-local code work.
- Risk: route convergence is improved but still needs continued scratch-rebuild observation in later iterations; if repeated `404` windows persist after the heartbeat contract change, the next fix belongs in startup/readiness gating rather than page clients.
## Next Checkpoints
- 2026-03-09: completed messaging wait fallback repair, gateway threshold normalization, and live rebuild verification.
- Next iteration: expand from route availability into deeper Playwright action sweeps on the rebuilt stack.

View File

@@ -0,0 +1,75 @@
# Sprint 20260309-012 - Router Live Quota Scope And Notify Dispatch Repairs
## Topic & Scope
- Repair the two remaining authenticated frontdoor regressions left after the full rebuild and redeploy: quota violations authorization and notify channel health dispatch.
- Keep the fixes in the Router layer because both failures occur before or inside Router-mediated delivery, not in the Platform or Notify business logic itself.
- Preserve existing live contracts while removing the actual transport/auth defects instead of adding route-local UI fallbacks.
- Working directory: `src/Router/`.
- Allowed coordination edits: `docs/modules/router/architecture.md`, `docs/modules/notify/architecture.md`, `docs/implplan/SPRINT_20260309_012_Router_live_quota_scope_and_notify_dispatch_repairs.md`, `src/Web/StellaOps.Web/output/playwright/**`.
- Expected evidence: targeted router test runs against individual `.csproj` files, rebuilt `router-gateway` image, redeployed compose stack, refreshed authenticated Playwright artifacts.
## Dependencies & Concurrency
- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the scratch rebuild baseline and `SPRINT_20260309_011_Platform_live_remaining_route_contract_repair.md` for the narrowed live failure inventory.
- Safe parallelism: do not touch unrelated search or component-revival work outside `src/Router/**`; leave unrelated dirty files untouched.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/router/architecture.md`
- `docs/modules/notify/architecture.md`
## Delivery Tracker
### LIVE-ROUTER-012-001 - Restore gateway scope compatibility for quota reads
Status: DONE
Dependency: none
Owners: Developer, Test Automation
Task description:
- Fix the gateway authorization path so live quota endpoints can honor the resolved scope set produced by identity scope expansion. The frontdoor currently rejects quota reads even though the authenticated session carries `orch:quota` and the gateway already computes expanded scopes in request context.
Completion criteria:
- [x] `/api/v1/gateway/rate-limits/violations` succeeds through the live frontdoor for the authenticated operator session.
- [x] Router gateway unit tests cover coarse-scope expansion and authorization checks against the resolved scope set.
- [x] Router docs describe that scope-based authorization uses the resolved scope context, not only raw claim payloads.
### LIVE-ROUTER-012-002 - Fix ASP.NET bridge route matching for notify health paths
Status: DONE
Dependency: none
Owners: Developer, Test Automation
Task description:
- Fix the messaging-transport ASP.NET bridge so terminal route parameters are not treated as implicit catch-alls. The notify channel-health route currently dispatches through messaging, and the bridge incorrectly matches the shorter channel-detail route when extra segments are present.
Completion criteria:
- [x] `/api/v1/notify/channels/{channelId}/health` resolves to the correct endpoint over Router messaging transport.
- [x] Router ASP.NET bridge tests reproduce the old terminal-parameter bug and prove explicit catch-all routes still work.
- [x] The fix is implemented in Router transport/bridge code, not in Notify page-local workarounds.
### LIVE-ROUTER-012-003 - Rebuild, redeploy, and reverify the live frontdoor
Status: DONE
Dependency: LIVE-ROUTER-012-001, LIVE-ROUTER-012-002
Owners: Developer, QA
Task description:
- Rebuild the touched router image, redeploy the live stack, and rerun authenticated Playwright verification for the two repaired pages before committing.
Completion criteria:
- [x] The changed Router image is rebuilt from current source and redeployed.
- [x] Authenticated Playwright rechecks pass for `/ops/operations/quotas` and `/ops/operations/notifications`.
- [x] The canonical route sweep artifact reflects the updated live failure inventory.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-09 | Sprint created after the full rebuild/redeploy cleared scanner-backed route failures and left only two live Router-layer defects: quota scope enforcement and notify channel-health dispatch over messaging transport. | Developer |
| 2026-03-09 | Added coarse-to-fine quota scope compatibility in gateway authorization, fixed ASP.NET bridge terminal-parameter matching, rebuilt `router-gateway` and `notify-web`, and verified live `/ops/operations/quotas` plus `/ops/operations/notifications` behavior with authenticated Playwright. | Developer |
| 2026-03-09 | Re-ran the authenticated canonical live sweep after the rebuild cycle; the latest artifact reached `111/111` at `src/Web/StellaOps.Web/output/playwright/live-frontdoor-canonical-route-sweep.json`. | QA |
## Decisions & Risks
- Decision: keep quota compatibility in Router by authorizing against the resolved scope context already produced by gateway identity expansion; do not broaden Platform policies or change token issuance.
- Decision: fix notify health in the ASP.NET bridge matcher so only explicit catch-all parameters consume extra path segments; this preserves direct HTTP and messaging parity.
- Risk: Router is a shared ingress surface. All changes must be covered by deterministic tests before redeploy to avoid collateral regressions in other routed pages.
- Decision: keep the live verification artifact in the sprint because the repaired quota and notify defects were validated in the same rebuilt stack that now serves the full canonical route set cleanly.
## Next Checkpoints
- 2026-03-09: completed router gateway and ASP.NET bridge repairs with focused tests plus live rebuild verification.
- Next iteration: continue beyond route presence into deeper per-page action sweeps on the rebuilt stack.

View File

@@ -18,6 +18,7 @@ Rollout policy: `docs/operations/multi-tenant-rollout-and-compatibility.md`
- 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
- Gateway scope authorization evaluates against the resolved per-request scope set from identity expansion (`GatewayContextKeys.Scopes`), so coarse compatibility scopes such as `orch:quota` can satisfy their fine-grained frontdoor equivalents without changing downstream policy names
### Transport Architecture
@@ -106,6 +107,8 @@ Browser → Router Gateway (port 80) → [microservices via binary transport]
The Angular SPA dist is provided by a `console-builder` init container that copies the built files to a shared `console-dist` volume mounted at `/app/wwwroot`.
When the gateway runs in-container, listener binding must honor explicit `ASPNETCORE_URLS` / `ASPNETCORE_HTTP_PORTS` / `ASPNETCORE_HTTPS_PORTS` values from compose. Wildcard hosts (`+`, `*`) are normalized to `0.0.0.0` before Kestrel listeners are created so the declared HTTP frontdoor contract actually comes up.
---
## Service Identity
@@ -177,6 +180,7 @@ public sealed class EndpointDescriptor
- ASP.NET-style route templates
- Parameter segments: `{id}`, `{userId}`
- Extra path segments are consumed only by explicit catch-all parameters (`{**path}`); ordinary terminal parameters must not behave like implicit catch-alls during messaging transport dispatch
- Case sensitivity and trailing slash handling follow ASP.NET conventions
---
@@ -533,6 +537,8 @@ Gateway tracks:
- Derives status from heartbeat recency
- Marks stale instances as Unhealthy
- Uses health in routing decisions
- Messaging transports stay push-first even when backed by notifiable queues; the missed-notification safety-net timeout is derived from the configured heartbeat interval and clamped to a short bounded window instead of falling back to a fixed long poll.
- Gateway degraded and stale transitions are normalized against the messaging heartbeat contract. A gateway may not mark an instance `Degraded` earlier than `2x` the heartbeat interval or `Unhealthy` earlier than `3x` the heartbeat interval, even when looser defaults were configured.
Periodic HELLO re-registration is valid so a microservice can repopulate gateway state after a gateway restart, but it must refresh the existing logical transport connection instead of minting a second one. Gateway routing state also deduplicates by service instance identity (`ServiceName`, `Version`, `InstanceId`, transport) before re-indexing endpoints so repeated HELLO frames cannot accumulate stale route candidates.

View File

@@ -1,7 +1,8 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using System.Text.Json;
namespace StellaOps.Gateway.WebService.Authorization;
@@ -66,6 +67,8 @@ public sealed class AuthorizationMiddleware
return;
}
var resolvedScopes = ResolveGatewayScopes(context);
foreach (var required in effectiveClaims)
{
var userClaims = context.User.Claims;
@@ -78,13 +81,7 @@ public sealed class AuthorizationMiddleware
else if (string.Equals(required.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(required.Type, "scp", StringComparison.OrdinalIgnoreCase))
{
// Scope claims may be space-separated (RFC 6749 §3.3) or individual claims.
// Check both: exact match on individual claims, and contains-within-space-separated.
hasClaim = userClaims.Any(c =>
c.Type == required.Type &&
(c.Value == required.Value ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, required.Value, StringComparison.Ordinal))));
hasClaim = HasRequiredScope(userClaims, resolvedScopes, required.Value);
}
else
{
@@ -108,6 +105,34 @@ public sealed class AuthorizationMiddleware
await _next(context);
}
private static ISet<string>? ResolveGatewayScopes(HttpContext context)
{
return context.Items.TryGetValue(GatewayContextKeys.Scopes, out var scopesObj) &&
scopesObj is ISet<string> scopes &&
scopes.Count > 0
? scopes
: null;
}
private static bool HasRequiredScope(
IEnumerable<Claim> userClaims,
ISet<string>? resolvedScopes,
string requiredScope)
{
if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope))
{
return true;
}
// Scope claims may be space-separated (RFC 6749 section 3.3) or individual claims.
return userClaims.Any(c =>
(string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase)) &&
(c.Value == requiredScope ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal))));
}
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;

View File

@@ -0,0 +1,73 @@
using System.Net;
namespace StellaOps.Gateway.WebService.Configuration;
/// <summary>
/// Resolves container listener URLs from ASP.NET Core's explicit URL and port environment variables.
/// </summary>
public static class ContainerFrontdoorBindingResolver
{
public static IReadOnlyList<Uri> ResolveConfiguredUrls(
string? serverUrls,
string? explicitUrls,
string? explicitHttpPorts,
string? explicitHttpsPorts)
{
var rawUrls = !string.IsNullOrWhiteSpace(serverUrls)
? SplitEntries(serverUrls)
: !string.IsNullOrWhiteSpace(explicitUrls)
? SplitEntries(explicitUrls)
: BuildUrlsFromPorts(explicitHttpPorts, explicitHttpsPorts);
return rawUrls
.Select(TryCreateListenUri)
.Where(static uri => uri is not null)
.Cast<Uri>()
.DistinctBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> BuildUrlsFromPorts(string? explicitHttpPorts, string? explicitHttpsPorts)
{
foreach (var port in SplitEntries(explicitHttpPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"http://0.0.0.0:{value}";
}
}
foreach (var port in SplitEntries(explicitHttpsPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"https://0.0.0.0:{value}";
}
}
}
private static IEnumerable<string> SplitEntries(string? value)
{
return string.IsNullOrWhiteSpace(value)
? []
: value.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static Uri? TryCreateListenUri(string rawUrl)
{
var normalized = NormalizeWildcardHost(rawUrl);
return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ? uri : null;
}
private static string NormalizeWildcardHost(string rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return string.Empty;
}
return rawUrl
.Replace("://+", $"://{IPAddress.Any}", StringComparison.Ordinal)
.Replace("://*", $"://{IPAddress.Any}", StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Configuration;
internal static class GatewayHealthThresholdPolicy
{
private static readonly TimeSpan DefaultMessagingHeartbeatInterval = TimeSpan.FromSeconds(10);
internal static void ApplyMinimums(HealthOptions options, GatewayMessagingTransportOptions messaging)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(messaging);
var heartbeatInterval = GatewayValueParser.ParseDuration(
messaging.HeartbeatInterval,
DefaultMessagingHeartbeatInterval);
if (heartbeatInterval <= TimeSpan.Zero)
{
heartbeatInterval = DefaultMessagingHeartbeatInterval;
}
var minimumDegradedThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 2);
var minimumStaleThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 3);
if (options.DegradedThreshold < minimumDegradedThreshold)
{
options.DegradedThreshold = minimumDegradedThreshold;
}
if (options.StaleThreshold < minimumStaleThreshold)
{
options.StaleThreshold = minimumStaleThreshold;
}
}
}

View File

@@ -433,6 +433,14 @@ public sealed class IdentityHeaderPolicyMiddleware
scopes.Add("scheduler.runs.preview");
scopes.Add("scheduler.runs.manage");
}
// Legacy operator sessions still mint orch:quota and rely on the gateway to
// resolve the equivalent fine-grained quota scopes before frontdoor auth.
if (scopes.Contains("orch:quota"))
{
scopes.Add("quota.read");
scopes.Add("quota.admin");
}
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)

View File

@@ -371,6 +371,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging);
});
builder.Services.AddOptions<OpenApiAggregationOptions>()
@@ -413,19 +414,18 @@ static bool ShouldApplyStellaOpsLocalBinding()
static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder)
{
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty;
var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey),
Environment.GetEnvironmentVariable("ASPNETCORE_URLS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"));
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
var defaultCert = LoadDefaultCertificate(context.Configuration);
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
foreach (var uri in currentUrls)
{
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
continue;
}
var address = ResolveListenAddress(uri.Host);
if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
@@ -537,4 +537,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Router Libraries (now in same module) -->
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />

View File

@@ -61,11 +61,12 @@
},
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "15s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
},
"Routes": [
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
@@ -79,9 +80,9 @@
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
{ "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true },

View File

@@ -705,12 +705,12 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
private sealed class TemplateMatcher
{
private readonly RoutePattern _pattern;
private readonly List<(string Segment, bool IsParameter, string? ParameterName)> _segments;
private readonly List<(string Segment, bool IsParameter, string? ParameterName, bool IsCatchAll)> _segments;
public TemplateMatcher(RoutePattern pattern)
{
_pattern = pattern;
_segments = new List<(string, bool, string?)>();
_segments = new List<(string, bool, string?, bool)>();
foreach (var segment in pattern.PathSegments)
{
@@ -719,10 +719,10 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
switch (segment.Parts[0])
{
case RoutePatternLiteralPart literal:
_segments.Add((literal.Content, false, null));
_segments.Add((literal.Content, false, null, false));
break;
case RoutePatternParameterPart param:
_segments.Add((param.Name, true, param.Name));
_segments.Add((param.Name, true, param.Name, param.IsCatchAll));
break;
}
}
@@ -735,7 +735,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
RoutePatternParameterPart par => $"{{{par.Name}}}",
_ => ""
}));
_segments.Add((combined, false, null));
_segments.Add((combined, false, null, false));
}
}
}
@@ -746,21 +746,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length != _segments.Count)
if (pathSegments.Length != _segments.Count)
{
// Only explicit catch-all parameters may consume extra path segments.
if (_segments.Count > 0 &&
_segments[^1].IsParameter &&
_segments[^1].IsCatchAll &&
pathSegments.Length >= _segments.Count - 1)
{
// Check for catch-all
if (_segments.Count > 0 &&
_segments[^1].IsParameter &&
pathSegments.Length >= _segments.Count - 1)
// Handle catch-all: remaining path goes into last parameter
for (var i = 0; i < _segments.Count - 1; i++)
{
// Handle catch-all: remaining path goes into last parameter
for (var i = 0; i < _segments.Count - 1; i++)
var (segment, isParam, paramName, _) = _segments[i];
if (isParam)
{
var (segment, isParam, paramName) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;
@@ -775,14 +776,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
return false;
}
for (var i = 0; i < _segments.Count; i++)
{
var (segment, isParam, paramName) = _segments[i];
for (var i = 0; i < _segments.Count; i++)
{
var (segment, isParam, paramName, _) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;

View File

@@ -157,11 +157,6 @@ public static class StellaRouterIntegrationHelper
throw new InvalidOperationException("Router is enabled but no gateway endpoints are configured.");
}
services.TryAddStellaRouter(
serviceName,
version,
routerOptions);
var configuredTransports = routerOptions.Gateways
.GroupBy(gateway => gateway.TransportType)
.Select(group => (TransportType: group.Key, Gateway: group.First()))
@@ -178,6 +173,18 @@ public static class StellaRouterIntegrationHelper
resolvedMessagingSection,
configuredTransports);
ApplyImplicitHeartbeatInterval(
routerOptions,
configuration,
transportConfiguration,
resolvedRouterOptionsSection,
configuredTransports);
services.TryAddStellaRouter(
serviceName,
version,
routerOptions);
var pluginLoader = CreateTransportPluginLoader(
transportConfiguration,
resolvedRouterOptionsSection);
@@ -371,6 +378,33 @@ public static class StellaRouterIntegrationHelper
.Build();
}
private static void ApplyImplicitHeartbeatInterval(
StellaRouterOptionsBase routerOptions,
IConfiguration configuration,
IConfiguration transportConfiguration,
string routerOptionsSection,
IEnumerable<(TransportType TransportType, StellaRouterGatewayOptionsBase Gateway)> configuredTransports)
{
if (HasExplicitHeartbeatInterval(configuration, routerOptionsSection) ||
!configuredTransports.Any(static transport => transport.TransportType == TransportType.Messaging))
{
return;
}
var resolvedMessagingSection = BuildResolvedTransportSection(ToTransportPluginName(TransportType.Messaging));
var fallback = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
var heartbeatInterval = ParseDuration(
transportConfiguration[$"{resolvedMessagingSection}:HeartbeatInterval"],
fallback);
if (heartbeatInterval <= TimeSpan.Zero)
{
return;
}
routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds));
}
private static void CopySectionValues(
IConfigurationSection section,
IDictionary<string, string?> destination,
@@ -487,6 +521,13 @@ public static class StellaRouterIntegrationHelper
}
}
private static bool HasExplicitHeartbeatInterval(
IConfiguration configuration,
string routerOptionsSection)
{
return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:HeartbeatIntervalSeconds"]);
}
private static RouterTransportPluginLoader CreateTransportPluginLoader(
IConfiguration configuration,
string routerOptionsSection)

View File

@@ -9,30 +9,58 @@ namespace StellaOps.Router.Transport.Messaging.Extensions;
/// </summary>
internal static class QueueWaitExtensions
{
/// <summary>
/// Default fallback timeout when the queue supports Pub/Sub notifications.
/// The wait returns early on notification; this is only a safety net.
/// </summary>
private static readonly TimeSpan NotifiableTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MinimumNotifiableTimeout = TimeSpan.FromSeconds(1);
private static readonly TimeSpan MaximumNotifiableTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Fallback delay for queues that do not implement <see cref="INotifiableQueue"/>.
/// </summary>
private static readonly TimeSpan PollingFallback = TimeSpan.FromSeconds(1);
/// <summary>
/// Resolves the safety-net timeout for notifiable queues.
/// The queue stays push-first; this timeout only covers missed notifications.
/// </summary>
internal static TimeSpan ResolveNotifiableTimeout(TimeSpan heartbeatInterval)
{
if (heartbeatInterval <= TimeSpan.Zero)
{
return MaximumNotifiableTimeout;
}
var derivedSeconds = Math.Max(
MinimumNotifiableTimeout.TotalSeconds,
Math.Floor(heartbeatInterval.TotalSeconds / 3d));
var derivedTimeout = TimeSpan.FromSeconds(derivedSeconds);
if (derivedTimeout > MaximumNotifiableTimeout)
{
return MaximumNotifiableTimeout;
}
return derivedTimeout;
}
/// <summary>
/// Waits for new messages to become available on the queue.
/// </summary>
public static Task WaitForMessagesAsync<TMessage>(
this IMessageQueue<TMessage> queue,
TimeSpan heartbeatInterval,
CancellationToken cancellationToken)
where TMessage : class
{
if (queue is INotifiableQueue notifiable)
{
return notifiable.WaitForNotificationAsync(NotifiableTimeout, cancellationToken);
return notifiable.WaitForNotificationAsync(ResolveNotifiableTimeout(heartbeatInterval), cancellationToken);
}
return Task.Delay(PollingFallback, cancellationToken);
}
public static Task WaitForMessagesAsync<TMessage>(
this IMessageQueue<TMessage> queue,
CancellationToken cancellationToken)
where TMessage : class
=> WaitForMessagesAsync(queue, TimeSpan.Zero, cancellationToken);
}

View File

@@ -206,7 +206,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _responseQueue.WaitForMessagesAsync(cancellationToken);
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -270,7 +270,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _serviceIncomingQueue.WaitForMessagesAsync(cancellationToken);
await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)

View File

@@ -173,7 +173,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _requestQueue.WaitForMessagesAsync(cancellationToken);
await _requestQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -217,7 +217,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _responseQueue.WaitForMessagesAsync(cancellationToken);
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)

View File

@@ -9,6 +9,10 @@
<RootNamespace>StellaOps.Router.Transport.Messaging</RootNamespace>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using Xunit;
@@ -232,6 +233,33 @@ public sealed class AuthorizationMiddlewareTests
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_ScopeRequirement_UsesResolvedGatewayScopes_WhenPresent()
{
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "orch:quota")
});
context.Items[GatewayContextKeys.Scopes] = new HashSet<string>(StringComparer.Ordinal)
{
"orch:quota",
"quota.read",
"quota.admin"
};
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "quota.read" },
new() { Type = "scope", Value = "orch:quota" }
});
await _middleware.InvokeAsync(context);
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
{

View File

@@ -0,0 +1,47 @@
using StellaOps.Gateway.WebService.Configuration;
namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class ContainerFrontdoorBindingResolverTests
{
[Fact]
public void ResolveConfiguredUrls_NormalizesWildcardHostsFromExplicitUrls()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: null,
explicitUrls: "http://+:80;http://*:8080",
explicitHttpPorts: null,
explicitHttpsPorts: null);
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
"http://0.0.0.0/",
"http://0.0.0.0:8080/");
}
[Fact]
public void ResolveConfiguredUrls_BuildsUrlsFromPortEnvironment()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: null,
explicitUrls: null,
explicitHttpPorts: "80,8080",
explicitHttpsPorts: "8443");
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
"http://0.0.0.0/",
"http://0.0.0.0:8080/",
"https://0.0.0.0:8443/");
}
[Fact]
public void ResolveConfiguredUrls_PrefersServerUrlsWhenProvided()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: "http://localhost:9090",
explicitUrls: "http://+:80",
explicitHttpPorts: "8080",
explicitHttpsPorts: null);
urls.Select(static uri => uri.AbsoluteUri).Should().Equal("http://localhost:9090/");
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class GatewayHealthThresholdPolicyTests
{
[Fact]
public void ApplyMinimums_RaisesThresholdsToHeartbeatContract()
{
var options = new HealthOptions
{
DegradedThreshold = TimeSpan.FromSeconds(15),
StaleThreshold = TimeSpan.FromSeconds(30),
CheckInterval = TimeSpan.FromSeconds(5)
};
var messaging = new GatewayMessagingTransportOptions
{
Enabled = true,
HeartbeatInterval = "10s"
};
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(20));
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void ApplyMinimums_PreservesHigherExplicitThresholds()
{
var options = new HealthOptions
{
DegradedThreshold = TimeSpan.FromSeconds(45),
StaleThreshold = TimeSpan.FromSeconds(90),
CheckInterval = TimeSpan.FromSeconds(5)
};
var messaging = new GatewayMessagingTransportOptions
{
Enabled = true,
HeartbeatInterval = "10s"
};
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(45));
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(90));
}
}

View File

@@ -307,6 +307,26 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
Assert.Contains("admin", scopes);
}
[Fact]
public async Task InvokeAsync_ExpandsLegacyQuotaScopeIntoResolvedQuotaScopes()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "orch:quota")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("orch:quota", scopes);
Assert.Contains("quota.read", scopes);
Assert.Contains("quota.admin", scopes);
}
[Fact]
public async Task InvokeAsync_ScopesAreSortedDeterministically()
{

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |
| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. |
| LIVE-ROUTER-012-T1 | DONE | 2026-03-09: Added quota compatibility regressions for coarse-scope expansion and authorization against resolved gateway scopes. |

View File

@@ -0,0 +1,65 @@
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Transport.Messaging.Extensions;
namespace StellaOps.Gateway.WebService.Tests.Transport;
public sealed class QueueWaitExtensionsTests
{
[Fact]
public async Task WaitForMessagesAsync_UsesHeartbeatDerivedTimeout_ForNotifiableQueues()
{
var queue = new RecordingNotifiableQueue();
await queue.WaitForMessagesAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
queue.LastTimeout.Should().Be(QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(10)));
}
[Theory]
[InlineData(10, 3)]
[InlineData(45, 5)]
[InlineData(1, 1)]
public void ResolveNotifiableTimeout_ClampsToExpectedBounds(int heartbeatSeconds, int expectedSeconds)
{
var timeout = QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(heartbeatSeconds));
timeout.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
}
private sealed class RecordingNotifiableQueue : IMessageQueue<TestMessage>, INotifiableQueue
{
public string ProviderName => "test";
public string QueueName => "test-queue";
public TimeSpan? LastTimeout { get; private set; }
public ValueTask<EnqueueResult> EnqueueAsync(
TestMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task WaitForNotificationAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
LastTimeout = timeout;
return Task.CompletedTask;
}
}
private sealed record TestMessage(string Value);
}

View File

@@ -281,6 +281,92 @@ public sealed class AspNetRouterRequestDispatcherTests
Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal);
}
[Fact]
public async Task DispatchAsync_DoesNotTreatTerminalRouteParameterAsImplicitCatchAll()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapGet(
"/api/v1/notify/channels/{channelId}",
(string channelId) => Guid.TryParse(channelId, out _)
? Results.Ok(new { route = "detail", channelId })
: Results.BadRequest(new { error = "channelId must be a GUID." }));
app.MapGet(
"/api/v1/notify/channels/{channelId}/health",
(string channelId) => Guid.TryParse(channelId, out _)
? Results.Ok(new { route = "health", channelId })
: Results.BadRequest(new { error = "channelId must be a GUID." }));
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 = "notify",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-notify-health-1",
Method = "GET",
Path = "/api/v1/notify/channels/e0000001-0000-0000-0000-000000000003/health",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
});
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Contains("\"route\":\"health\"", responseBody, StringComparison.Ordinal);
Assert.Contains("\"channelId\":\"e0000001-0000-0000-0000-000000000003\"", responseBody, StringComparison.Ordinal);
}
[Fact]
public async Task DispatchAsync_ExplicitCatchAllRouteStillConsumesRemainingSegments()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapGet("/api/v1/files/{**path}", (string path) => Results.Ok(new { path }));
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 = "files",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-files-catchall-1",
Method = "GET",
Path = "/api/v1/files/a/b/c.txt",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
});
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Contains("\"path\":\"a/b/c.txt\"", responseBody, StringComparison.Ordinal);
}
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
{
var services = new ServiceCollection();

View File

@@ -216,11 +216,13 @@ public sealed class StellaRouterIntegrationHelperTests
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
var messaging = provider.GetRequiredService<IOptions<MessagingTransportOptions>>().Value;
var valkey = provider.GetRequiredService<IOptions<StellaOps.Messaging.Transport.Valkey.ValkeyTransportOptions>>().Value;
var transport = provider.GetRequiredService<IMicroserviceTransport>();
Assert.True(result);
Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
Assert.Equal("router:responses", messaging.ResponseQueueName);
Assert.Equal("timelineindexer", messaging.ConsumerGroup);

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| 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. |
| LIVE-ROUTER-012-T2 | DONE | 2026-03-09: Added ASP.NET bridge regressions to prevent implicit catch-all matching for terminal route parameters while preserving explicit `{**path}` behavior. |