Fix router frontdoor readiness and route contracts

This commit is contained in:
master
2026-03-10 10:19:49 +02:00
parent eae2dfc9d4
commit 7acf0ae8f2
37 changed files with 1408 additions and 1914 deletions

View File

@@ -126,20 +126,19 @@ curl -fsS http://127.1.1.3:8080/status
docker compose -f docker-compose.stella-ops.yml logs -f scanner-web
```
### Router Mode Switching
### Router Frontdoor Configuration
`router-gateway` now supports a compose-driven route table switch via `ROUTER_GATEWAY_CONFIG`.
`router-gateway` uses the microservice-first route table in `router-gateway-local.json`.
First-party Stella APIs are expected to flow through router transport; reverse proxy remains only for
external/bootstrap surfaces that cannot participate in router registration yet (for example OIDC browser
flows, Rekor, and static/platform bootstrap assets).
```bash
# Default mode: microservice routing over Valkey messaging
# Default frontdoor route table
ROUTER_GATEWAY_CONFIG=./router-gateway-local.json \
docker compose -f docker-compose.stella-ops.yml up -d
# Reverse-proxy fallback mode (no route-table edits required)
ROUTER_GATEWAY_CONFIG=./router-gateway-local.reverseproxy.json \
docker compose -f docker-compose.stella-ops.yml up -d
# Optional: mode switch helper with health recovery + header-search smoke checks
# Optional: scratch redeploy helper with health recovery + header-search smoke checks
pwsh ./scripts/router-mode-redeploy.ps1 -Mode microservice
```

View File

@@ -373,7 +373,7 @@ services:
- router.stella-ops.local
- stella-ops.local
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8080'"]
test: ["CMD-SHELL", "bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n\" >&3 && head -n 1 <&3 | grep -q \"200\"'"]
<<: *healthcheck-tcp
labels: *release-labels

View File

@@ -27,14 +27,13 @@ VALKEY_PORT=6379
RUSTFS_HTTP_PORT=8333
# =============================================================================
# ROUTER GATEWAY MODE
# ROUTER GATEWAY
# =============================================================================
# Router route table file mounted to /app/appsettings.local.json
# Microservice + Valkey mode (default):
# Microservice-first frontdoor config (default).
# Reverse proxy is intentionally limited to external/bootstrap surfaces inside this file.
ROUTER_GATEWAY_CONFIG=./router-gateway-local.json
# Reverse-proxy fallback mode:
# ROUTER_GATEWAY_CONFIG=./router-gateway-local.reverseproxy.json
# Authority claims override endpoint base URL consumed by router-gateway.
ROUTER_AUTHORITY_CLAIMS_OVERRIDES_URL=http://authority.stella-ops.local

View File

@@ -1,23 +1,23 @@
{
"Gateway": {
{
"Gateway": {
"Auth": {
"DpopEnabled": false,
"AllowAnonymous": true,
"EnableLegacyHeaders": true,
"AllowScopeHeader": false,
"ApprovedAuthPassthroughPrefixes": [
"/connect",
"/console",
"/authority",
"/doctor",
"/api",
"/policy/shadow",
"/policy/simulations"
],
"Authority": {
"Issuer": "https://authority.stella-ops.local/",
"RequireHttpsMetadata": false,
"MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration",
"AllowScopeHeader": false,
"ApprovedAuthPassthroughPrefixes": [
"/connect",
"/console",
"/authority",
"/doctor",
"/api",
"/policy/shadow",
"/policy/simulations"
],
"Authority": {
"Issuer": "https://authority.stella-ops.local/",
"RequireHttpsMetadata": false,
"MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration",
"Audiences": [
]
@@ -30,800 +30,128 @@
"RequiredMicroservices": [
"platform",
"policy",
"policy-engine",
"notify",
"notifier",
"scanner",
"findings",
"findings-ledger",
"integrations",
"reachgraph",
"attestor",
"evidence",
"sbom",
"evidencelocker",
"sbomservice",
"jobengine",
"authority",
"vex",
"vexhub",
"concelier"
]
},
"Routes": [
{
"Type": "ReverseProxy",
"Path": "/api/v1/setup",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/setup",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/release-orchestrator",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/approvals",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/vex",
"TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/vexlens",
"TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/notify",
"TranslatesTo": "http://notify.stella-ops.local/api/v1/notify",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/notifier",
"TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/concelier",
"TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/platform",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/platform",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/scanner",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/findings",
"TranslatesTo": "http://findings-ledger.stella-ops.local/api/v1/findings",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/integrations",
"TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/reachability",
"TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/attestor",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/attestations",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/sbom",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/signals",
"TranslatesTo": "http://signals.stella-ops.local/api/v1/signals",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/jobengine",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/authority/quotas",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/authority",
"TranslatesTo": "https://authority.stella-ops.local/api/v1/authority",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/trust",
"TranslatesTo": "https://authority.stella-ops.local/api/v1/trust",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/evidence",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/evidence",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/proofs",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/proofs",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/timeline",
"TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/audit",
"TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/advisory-sources",
"TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/notifier/delivery",
"TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/release-control",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/context",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/context",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/releases",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/releases",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/security",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/security",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/topology",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/topology",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/integrations",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/authority/console",
"TranslatesTo": "https://authority.stella-ops.local/console",
"PreserveAuthHeaders": true
},
{ "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" },
{ "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" },
{ "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/secrets(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sources(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/witnesses(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses$1" },
{ "Type": "Microservice", "Path": "^/api/v1/trust(.*)", "IsRegex": true, "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust$1" },
{ "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/proofs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals$1" },
{ "Type": "Microservice", "Path": "^/api/v1/attestations(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sbom(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom$1" },
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
{ "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" },
{ "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" },
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
{ "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/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" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer$1" },
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/vex$1" },
{ "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": "^/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" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "Microservice", "Path": "^/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
{ "Type": "Microservice", "Path": "^/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/v1/audit-bundles(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/v1/audit-bundles$1" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local/connect" },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known" },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/rekor", "TranslatesTo": "http://rekor.stella-ops.local:3322", "PreserveAuthHeaders": false },
{ "Type": "ReverseProxy", "Path": "/platform/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/api/v1/setup", "TranslatesTo": "http://platform.stella-ops.local/api/v1/setup" },
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-ai/adapters",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/search",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/search",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-ai",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/v1/evidence-packs",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/advisory",
"TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/vulnerabilities",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/watchlist",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/resolve",
"TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/ops/binaryindex",
"TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/verdicts",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/verdicts",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/lineage",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/export",
"TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/triage",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/governance",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/determinization",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/opsmemory",
"TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/secrets",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/sources",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/workflows",
"TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/witnesses",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/v1/runs",
"TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/v1/advisory-ai/adapters",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/v1/advisory-ai",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/v1/audit-bundles",
"TranslatesTo": "https://exportcenter.stella-ops.local/v1/audit-bundles",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/cvss",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/risk",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/risk",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/analytics",
"TranslatesTo": "http://platform.stella-ops.local/api/analytics",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/release-orchestrator",
"TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/releases",
"TranslatesTo": "http://jobengine.stella-ops.local/api/releases",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/approvals",
"TranslatesTo": "http://jobengine.stella-ops.local/api/approvals",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/gate",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/risk-budget",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/fix-verification",
"TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/compare",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/compare",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/change-traces",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/exceptions",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/verdicts",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/jobengine",
"TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/v1/gateway/rate-limits",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/sbomservice",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/vuln-explorer",
"TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/vex",
"TranslatesTo": "https://vexhub.stella-ops.local/api/vex",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/admin/plans",
"TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/admin",
"TranslatesTo": "http://platform.stella-ops.local/api/admin",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/api/scheduler",
"TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/doctor/scheduler",
"TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/doctor",
"TranslatesTo": "http://doctor.stella-ops.local/api/doctor",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api",
"TranslatesTo": "http://platform.stella-ops.local/api",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/platform/envsettings.json",
"TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/platform",
"TranslatesTo": "http://platform.stella-ops.local/platform",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/connect",
"TranslatesTo": "http://authority.stella-ops.local/connect",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/.well-known",
"TranslatesTo": "http://authority.stella-ops.local/.well-known",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/jwks",
"TranslatesTo": "http://authority.stella-ops.local/jwks",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/authority",
"TranslatesTo": "https://authority.stella-ops.local/authority",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/console",
"TranslatesTo": "https://authority.stella-ops.local/console",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/rekor",
"TranslatesTo": "http://rekor.stella-ops.local:3322"
},
{
"Type": "ReverseProxy",
"Path": "/envsettings.json",
"TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/scanner",
"TranslatesTo": "http://scanner.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/policyGateway",
"TranslatesTo": "http://policy-gateway.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/policyEngine",
"TranslatesTo": "http://policy-engine.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/concelier",
"TranslatesTo": "http://concelier.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/attestor",
"TranslatesTo": "http://attestor.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/notify",
"TranslatesTo": "http://notify.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/notifier",
"TranslatesTo": "http://notifier.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/scheduler",
"TranslatesTo": "http://scheduler.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/signals",
"TranslatesTo": "http://signals.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/excititor",
"TranslatesTo": "http://excititor.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/findingsLedger",
"TranslatesTo": "http://findings-ledger.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/vexhub",
"TranslatesTo": "https://vexhub.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/vexlens",
"TranslatesTo": "http://vexlens.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/jobengine",
"TranslatesTo": "http://orchestrator.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/taskrunner",
"TranslatesTo": "http://taskrunner.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/cartographer",
"TranslatesTo": "http://cartographer.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/reachgraph",
"TranslatesTo": "http://reachgraph.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/doctor",
"TranslatesTo": "http://doctor.stella-ops.local",
"PreserveAuthHeaders": true
},
{
"Type": "Microservice",
"Path": "/integrations",
"TranslatesTo": "http://integrations.stella-ops.local"
"Type": "StaticFiles",
"Path": "/",
"TranslatesTo": "/app/wwwroot",
"Headers": {
"x-spa-fallback": "true"
}
},
{
"Type": "Microservice",
"Path": "/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local/policy",
"PreserveAuthHeaders": true
"Type": "NotFoundPage",
"Path": "/_error/404",
"TranslatesTo": "/app/wwwroot/index.html"
},
{
"Type": "Microservice",
"Path": "/replay",
"TranslatesTo": "http://replay.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/exportcenter",
"TranslatesTo": "https://exportcenter.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/evidencelocker",
"TranslatesTo": "https://evidencelocker.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/signer",
"TranslatesTo": "http://signer.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/binaryindex",
"TranslatesTo": "http://binaryindex.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/riskengine",
"TranslatesTo": "http://riskengine.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/vulnexplorer",
"TranslatesTo": "http://vulnexplorer.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/sbomservice",
"TranslatesTo": "http://sbomservice.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/advisoryai",
"TranslatesTo": "http://advisoryai.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/unknowns",
"TranslatesTo": "http://unknowns.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/timelineindexer",
"TranslatesTo": "http://timelineindexer.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/opsmemory",
"TranslatesTo": "http://opsmemory.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/issuerdirectory",
"TranslatesTo": "http://issuerdirectory.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/symbols",
"TranslatesTo": "http://symbols.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/packsregistry",
"TranslatesTo": "http://packsregistry.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/registryTokenservice",
"TranslatesTo": "http://registry-token.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/airgapController",
"TranslatesTo": "http://airgap-controller.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/airgapTime",
"TranslatesTo": "http://airgap-time.stella-ops.local"
},
{
"Type": "Microservice",
"Path": "/smremote",
"TranslatesTo": "http://smremote.stella-ops.local"
},
{
"Type": "StaticFiles",
"Path": "/",
"TranslatesTo": "/app/wwwroot",
"Headers": {
"x-spa-fallback": "true"
}
},
{
"Type": "NotFoundPage",
"Path": "/_error/404",
"TranslatesTo": "/app/wwwroot/index.html"
},
{
"Type": "ServerErrorPage",
"Path": "/_error/500",
"TranslatesTo": "/app/wwwroot/index.html"
}
]
},
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Authentication": "Information",
"Microsoft.IdentityModel": "Information",
"StellaOps": "Information"
}
}
}
"Type": "ServerErrorPage",
"Path": "/_error/500",
"TranslatesTo": "/app/wwwroot/index.html"
}
]
},
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Authentication": "Information",
"Microsoft.IdentityModel": "Information",
"StellaOps": "Information"
}
}
}

View File

@@ -1,812 +0,0 @@
{
"_deprecated": "Legacy fallback config. The canonical default is router-gateway-local.json (Microservice routing via Valkey). Use ROUTER_GATEWAY_CONFIG=./router-gateway-local.reverseproxy.json only when debugging transport issues. Will be removed in a future release.",
"Gateway": {
"Auth": {
"DpopEnabled": false,
"AllowAnonymous": true,
"EnableLegacyHeaders": true,
"AllowScopeHeader": false,
"ApprovedAuthPassthroughPrefixes": [
"/connect",
"/console",
"/authority",
"/doctor",
"/api",
"/policy/shadow",
"/policy/simulations"
],
"Authority": {
"Issuer": "https://authority.stella-ops.local/",
"RequireHttpsMetadata": false,
"MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration",
"Audiences": [
]
}
},
"Routes": [
{
"Type": "ReverseProxy",
"Path": "/api/v1/release-orchestrator",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/approvals",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/vex",
"TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/vexlens",
"TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/notify",
"TranslatesTo": "http://notify.stella-ops.local/api/v1/notify",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/notifier/delivery",
"TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/notifier",
"TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/concelier",
"TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-sources",
"TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/release-control",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/platform",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/platform",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/scanner",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/findings",
"TranslatesTo": "http://findings.stella-ops.local/api/v1/findings",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/integrations",
"TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/reachability",
"TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/attestor",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/attestations",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/sbom",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/signals",
"TranslatesTo": "http://signals.stella-ops.local/signals",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/jobengine",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/authority/quotas",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/authority",
"TranslatesTo": "https://authority.stella-ops.local/api/v1/authority",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/trust",
"TranslatesTo": "https://authority.stella-ops.local/api/v1/trust",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/evidence",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/evidence",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/proofs",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/proofs",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/timeline",
"TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/audit",
"TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-ai/adapters",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/search",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/search",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory-ai",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/advisory",
"TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/vulnerabilities",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/watchlist",
"TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/resolve",
"TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/ops/binaryindex",
"TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/verdicts",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/verdicts",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/lineage",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/export",
"TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/triage",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/governance",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/determinization",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/opsmemory",
"TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/secrets",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/sources",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/workflows",
"TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/witnesses",
"TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/v1/evidence-packs",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/v1/runs",
"TranslatesTo": "http://jobengine.stella-ops.local/v1/runs",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/v1/advisory-ai/adapters",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/v1/advisory-ai",
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/v1/audit-bundles",
"TranslatesTo": "https://exportcenter.stella-ops.local/v1/audit-bundles",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/cvss",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/policy/simulations",
"TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/policy/shadow",
"TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/policy",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/risk",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/risk",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/analytics",
"TranslatesTo": "http://platform.stella-ops.local/api/analytics",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/release-orchestrator",
"TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/releases",
"TranslatesTo": "http://jobengine.stella-ops.local/api/releases",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/approvals",
"TranslatesTo": "http://jobengine.stella-ops.local/api/approvals",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/gate",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/risk-budget",
"TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget",
"PreserveAuthHeaders": false
},
{
"Type": "ReverseProxy",
"Path": "/api/fix-verification",
"TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/compare",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/compare",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/change-traces",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/exceptions",
"TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/verdicts",
"TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/jobengine",
"TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/gateway/rate-limits",
"TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/sbomservice",
"TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/vuln-explorer",
"TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/vex",
"TranslatesTo": "https://vexhub.stella-ops.local/api/vex",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/admin/plans",
"TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/admin",
"TranslatesTo": "http://platform.stella-ops.local/api/admin",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/scheduler",
"TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v1/doctor/scheduler",
"TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/doctor",
"TranslatesTo": "http://doctor.stella-ops.local/api/doctor",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/context",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/context",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/releases",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/releases",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/security",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/security",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/topology",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/topology",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api/v2/integrations",
"TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/api",
"TranslatesTo": "http://platform.stella-ops.local/api",
"PreserveAuthHeaders": true
},
{
"Type": "StaticFile",
"Path": "/platform/envsettings.json",
"TranslatesTo": "/app/envsettings-override.json"
},
{
"Type": "ReverseProxy",
"Path": "/platform",
"TranslatesTo": "http://platform.stella-ops.local/platform"
},
{
"Type": "ReverseProxy",
"Path": "/connect",
"TranslatesTo": "https://authority.stella-ops.local/connect",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/.well-known",
"TranslatesTo": "https://authority.stella-ops.local/well-known",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/jwks",
"TranslatesTo": "https://authority.stella-ops.local/jwks",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/authority/console",
"TranslatesTo": "https://authority.stella-ops.local/console",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/authority",
"TranslatesTo": "https://authority.stella-ops.local/authority",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/console",
"TranslatesTo": "https://authority.stella-ops.local/console",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/rekor",
"TranslatesTo": "http://rekor.stella-ops.local:3322"
},
{
"Type": "ReverseProxy",
"Path": "/envsettings.json",
"TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json"
},
{
"Type": "ReverseProxy",
"Path": "/scanner",
"TranslatesTo": "http://scanner.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/policyGateway",
"TranslatesTo": "http://policy-gateway.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/policyEngine",
"TranslatesTo": "http://policy-engine.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/concelier",
"TranslatesTo": "http://concelier.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/attestor",
"TranslatesTo": "http://attestor.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/notify",
"TranslatesTo": "http://notify.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/notifier",
"TranslatesTo": "http://notifier.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/scheduler",
"TranslatesTo": "http://scheduler.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/signals",
"TranslatesTo": "http://signals.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/excititor",
"TranslatesTo": "http://excititor.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/findingsLedger",
"TranslatesTo": "http://findings.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/vexhub",
"TranslatesTo": "https://vexhub.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/vexlens",
"TranslatesTo": "http://vexlens.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/jobengine",
"TranslatesTo": "http://jobengine.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/taskrunner",
"TranslatesTo": "http://taskrunner.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/cartographer",
"TranslatesTo": "http://cartographer.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/reachgraph",
"TranslatesTo": "http://reachgraph.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/doctor",
"TranslatesTo": "http://doctor.stella-ops.local",
"PreserveAuthHeaders": true
},
{
"Type": "ReverseProxy",
"Path": "/integrations",
"TranslatesTo": "http://integrations.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/replay",
"TranslatesTo": "http://replay.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/exportcenter",
"TranslatesTo": "https://exportcenter.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/evidencelocker",
"TranslatesTo": "https://evidencelocker.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/signer",
"TranslatesTo": "http://signer.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/binaryindex",
"TranslatesTo": "http://binaryindex.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/riskengine",
"TranslatesTo": "http://riskengine.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/vulnexplorer",
"TranslatesTo": "http://vulnexplorer.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/sbomservice",
"TranslatesTo": "http://sbomservice.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/advisoryai",
"TranslatesTo": "http://advisoryai.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/unknowns",
"TranslatesTo": "http://unknowns.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/timelineindexer",
"TranslatesTo": "http://timelineindexer.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/opsmemory",
"TranslatesTo": "http://opsmemory.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/issuerdirectory",
"TranslatesTo": "http://issuerdirectory.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/symbols",
"TranslatesTo": "http://symbols.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/packsregistry",
"TranslatesTo": "http://packsregistry.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/registryTokenservice",
"TranslatesTo": "http://registry-token.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/airgapController",
"TranslatesTo": "http://airgap-controller.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/airgapTime",
"TranslatesTo": "http://airgap-time.stella-ops.local"
},
{
"Type": "ReverseProxy",
"Path": "/smremote",
"TranslatesTo": "http://smremote.stella-ops.local"
},
{
"Type": "StaticFiles",
"Path": "/",
"TranslatesTo": "/app/wwwroot",
"Headers": {
"x-spa-fallback": "true"
}
},
{
"Type": "NotFoundPage",
"Path": "/_error/404",
"TranslatesTo": "/app/wwwroot/index.html"
},
{
"Type": "ServerErrorPage",
"Path": "/_error/500",
"TranslatesTo": "/app/wwwroot/index.html"
}
]
},
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.Authentication": "Information",
"Microsoft.IdentityModel": "Information",
"StellaOps": "Information"
}
}
}

View File

@@ -1,5 +1,5 @@
param(
[ValidateSet("microservice", "reverseproxy")]
[ValidateSet("microservice")]
[string]$Mode = "microservice",
[string]$ComposeFile = "docker-compose.stella-ops.yml",
[int]$WaitTimeoutSeconds = 1200,
@@ -25,7 +25,6 @@ if (-not (Test-Path -LiteralPath $resolvedComposeFile)) {
$configFileName = switch ($Mode) {
"microservice" { "router-gateway-local.json" }
"reverseproxy" { "router-gateway-local.reverseproxy.json" }
default { throw "Unsupported mode: $Mode" }
}

View File

@@ -0,0 +1,95 @@
# Sprint 20260310-001 - Router Frontdoor Required-Service Readiness
## Topic & Scope
- Replace the gateway's shallow "listener started" readiness contract with a required-service registration gate so scratch rebuilds do not expose first-party Stella routes before their router HELLO registrations exist.
- Return truthful `503` responses for matched microservice routes whose target service is not yet registered instead of misleading `404` errors that make reverse proxy look safer than router transport.
- Keep reverse proxy limited to external/bootstrap surfaces and document the rule explicitly for the local compose frontdoor.
- Working directory: `src/Router`.
- Allowed coordination edits: `devops/compose/docker-compose.stella-ops.yml`, `devops/compose/router-gateway-local.json`, `devops/compose/README.md`, `devops/compose/env/stellaops.env.example`, `docs/modules/router/architecture.md`, `docs/implplan/SPRINT_20260310_001_Router_frontdoor_required_service_readiness.md`.
- Expected evidence: focused router tests, live gateway readiness probes before/after restart, and a rerun of the affected Playwright/live route checks after redeploy.
## Dependencies & Concurrency
- Follows `SPRINT_20260309_008_Router_live_messaging_heartbeat_contract_repair.md`, which already narrowed the remaining post-redeploy failures to startup/readiness convergence.
- Safe parallelism: stay inside the router slice and the listed compose/docs files; do not touch unrelated search, reachability, or general frontend feature work.
## 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`
- `docs/modules/router/webservices-valkey-rollout-matrix.md`
- `docs/qa/feature-checks/FLOW.md`
## Delivery Tracker
### ROUTER-READY-001 - Add required-service readiness evaluation
Status: DONE
Dependency: none
Owners: Developer, QA
Task description:
- Introduce a source-level readiness evaluator that keeps `/health/ready` false until the configured required first-party microservices have live healthy/degraded router registrations.
- Preserve environment ownership of the required-service list so the local scratch compose stack can demand a stricter frontdoor than lighter dev configurations.
Completion criteria:
- [x] Gateway health options support a required microservice list.
- [x] `/health/ready` returns `503` with missing-service details until all configured required services are registered.
- [x] Focused router tests cover both missing and satisfied readiness states.
### ROUTER-READY-002 - Return truthful warm-up failures for missing target registrations
Status: DONE
Dependency: ROUTER-READY-001
Owners: Developer, QA
Task description:
- When a route is already classified as `Microservice` but the target service has not registered, return a service-unavailable contract instead of `404`.
- Keep `404` only for genuinely unknown paths or endpoints that do not exist on a registered target service.
Completion criteria:
- [x] Targeted microservice-route misses return `503`.
- [x] Registered target service with a missing endpoint still returns `404`.
- [x] Focused middleware tests prove the distinction.
### ROUTER-READY-003 - Make scratch compose wait for the real frontdoor
Status: DONE
Dependency: ROUTER-READY-002
Owners: Developer, QA
Task description:
- Update the mounted local router config with the required-service list for the client-ready scratch stack and make the router-gateway container healthcheck probe `/health/ready` instead of only testing for an open TCP port.
- Document the reverse-proxy exception rule: external/bootstrap only, first-party Stella APIs through router transport.
Completion criteria:
- [x] `router-gateway-local.json` declares the required first-party services for the local scratch stack.
- [x] `docker-compose.stella-ops.yml` checks router readiness instead of raw port openness.
- [x] Router architecture docs describe the readiness gate and the reverse-proxy exception rule.
### ROUTER-READY-004 - Bound microservice HELLO recovery after gateway restart
Status: DONE
Dependency: ROUTER-READY-003
Owners: Developer, QA
Task description:
- Remove the hidden fixed 30-heartbeat HELLO replay heuristic from the microservice SDK and replace it with an explicit registration refresh interval that repopulates gateway state within seconds after a gateway restart.
- Flow the setting through the shared ASP.NET router integration so services can keep the default bounded contract or override it intentionally.
Completion criteria:
- [x] Stella microservice options expose a positive registration refresh interval.
- [x] Router connection manager replays HELLO on the configured interval without waiting for dozens of heartbeats.
- [x] Focused SDK and integration-helper tests cover the new contract.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-10 | Sprint created after live evidence showed the gateway was returning `404 TargetService=(none)` during post-redeploy convergence even though the mounted route table and aggregated OpenAPI already knew the affected first-party paths. | Developer |
| 2026-03-10 | Live restart evidence showed the deeper recovery gap: services only replayed HELLO every 30 heartbeats, leaving the gateway honestly unready for minutes after restart. Added a bounded HELLO refresh task under the same sprint. | Developer |
| 2026-03-10 | Audited the frontdoor refactor end to end: focused router tests passed, fresh-stack redeploy converged on `/health/ready`, restart probes now return `503` for missing target registrations before flipping to endpoint-level `404`, and the Playwright canonical route sweep rerun isolated the remaining failures to unrelated frontend routes under `/ops/policy`, `/ops/operations/*`, and trust-signing. | Developer |
## Decisions & Risks
- Decision: readiness is environment-owned. The gateway source exposes the contract, while the local compose stack opts into a concrete required-service list for scratch QA.
- Decision: reverse proxy remains valid for external/bootstrap surfaces such as Rekor, OIDC/browser flows, and SPA/static assets; it is not the preferred path for first-party Stella APIs.
- Decision: HELLO recovery is now time-based and explicit rather than a hidden multiple of heartbeat count. The default registration refresh interval is 10 seconds so a gateway restart cannot strand first-party routes behind stale state for minutes.
- Decision: the dedicated `router-gateway-local.reverseproxy.json` fallback mode is removed from active compose guidance. The supported scratch stack uses the microservice-first table with narrowly-scoped reverse proxy exceptions inside the same config.
- Risk: if the required-service list is too broad for the current compose footprint, `/health/ready` could remain false. Mitigation: use the actual mounted local stack as the authority and verify registrations live after redeploy.
## Next Checkpoints
- 2026-03-10: land readiness evaluation and route-level `503` contract.
- 2026-03-10: rebuild router-gateway, redeploy, and verify restart behavior with live probes.
- 2026-03-10: rerun the targeted Playwright/router checks on the warmed stack.

View File

@@ -85,6 +85,8 @@ Route types:
| `NotFoundPage` | HTML file served on 404 (after all other middleware) |
| `ServerErrorPage` | HTML file served on 5xx (after all other middleware) |
Reverse proxy is reserved for external/bootstrap surfaces such as OIDC browser flows, Rekor, and frontdoor static assets. First-party Stella API surfaces are expected to use `Microservice` routing so the gateway remains the single routing authority instead of silently bypassing router registration state.
### Pipeline Order
System paths (`/health`, `/metrics`, `/openapi.*`) bypass the route table entirely. The dispatch middleware runs before the microservice pipeline:
@@ -540,6 +542,9 @@ Gateway tracks:
- 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.
- `/health/ready` is stricter than "process started": it remains `503` until the configured required first-party microservices have live healthy or degraded registrations in router state. Local scratch compose uses this to hold the frontdoor unhealthy until the core Stella API surface has replayed HELLO after a rebuild.
- The required-service list must use canonical router `serviceName` values, not loose product-family aliases. Gateway readiness normalizes host-style suffixes such as `-gateway`, `-web`, `.stella-ops.local`, and ports, but it does not treat sibling services as interchangeable.
- When a request already matched a configured `Microservice` route but the target service has not registered yet, the gateway returns `503 Service Unavailable`, not `404 Not Found`. `404` remains reserved for genuinely unknown paths or missing endpoints on an otherwise registered service.
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

@@ -268,4 +268,6 @@ public sealed class GatewayHealthOptions
public string DegradedThreshold { get; set; } = "15s";
public string CheckInterval { get; set; } = "5s";
public List<string> RequiredMicroservices { get; set; } = [];
}

View File

@@ -40,6 +40,11 @@ public static class GatewayOptionsValidator
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
if (options.Health.RequiredMicroservices.Any(service => string.IsNullOrWhiteSpace(service)))
{
throw new InvalidOperationException("Gateway health required microservices must not contain empty values.");
}
ValidateRoutes(options.Routes);
}
@@ -133,6 +138,23 @@ public static class GatewayOptionsValidator
{
_ = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
}
if (route.IsRegex && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var regex = new Regex(route.Path);
var groupCount = regex.GetGroupNumbers().Length;
var refs = Regex.Matches(route.TranslatesTo, @"\$(\d+)");
foreach (Match refMatch in refs)
{
var groupNum = int.Parse(refMatch.Groups[1].Value);
if (groupNum >= groupCount)
{
throw new InvalidOperationException(
$"{prefix}: TranslatesTo references ${groupNum} but regex only has {groupCount - 1} capture groups.");
}
}
}
break;
}
}

View File

@@ -20,7 +20,11 @@ public sealed class HealthCheckMiddleware
_next = next;
}
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
public async Task InvokeAsync(
HttpContext context,
GatewayServiceStatus status,
GatewayMetrics metrics,
GatewayReadinessEvaluator readinessEvaluator)
{
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
{
@@ -37,28 +41,34 @@ public sealed class HealthCheckMiddleware
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
{
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status, readinessEvaluator.Evaluate(status));
return;
}
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
{
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status);
var readiness = readinessEvaluator.Evaluate(status);
var readyStatus = readiness.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status, readiness);
return;
}
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
{
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, startupStatus, "startup", status);
await WriteHealthAsync(context, startupStatus, "startup", status, readinessEvaluator.Evaluate(status));
return;
}
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status, readinessEvaluator.Evaluate(status));
}
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
private static Task WriteHealthAsync(
HttpContext context,
int statusCode,
string status,
GatewayServiceStatus serviceStatus,
GatewayReadinessReport readiness)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
@@ -67,7 +77,10 @@ public sealed class HealthCheckMiddleware
{
status,
started = serviceStatus.IsStarted,
ready = serviceStatus.IsReady,
ready = readiness.IsReady,
transportReady = serviceStatus.IsReady,
requiredMicroservices = readiness.RequiredMicroservices,
missingMicroservices = readiness.MissingMicroservices,
traceId = context.TraceIdentifier
};

View File

@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Gateway.WebService.Configuration;
@@ -47,7 +48,7 @@ public sealed class RouteDispatchMiddleware
return;
}
var route = _resolver.Resolve(context.Request.Path);
var (route, regexMatch) = _resolver.Resolve(context.Request.Path);
if (route is null)
{
await _next(context);
@@ -83,13 +84,13 @@ public sealed class RouteDispatchMiddleware
await HandleStaticFile(context, route);
break;
case StellaOpsRouteType.ReverseProxy:
await HandleReverseProxy(context, route);
await HandleReverseProxy(context, route, regexMatch);
break;
case StellaOpsRouteType.WebSocket:
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
PrepareMicroserviceRoute(context, route);
PrepareMicroserviceRoute(context, route, regexMatch);
await _next(context);
break;
default:
@@ -178,17 +179,24 @@ public sealed class RouteDispatchMiddleware
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route)
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
var captureGroupsResolved = !string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal);
var remainingPath = requestPath;
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
if (captureGroupsResolved)
{
// Capture groups resolved: TranslatesTo already contains the full target path.
remainingPath = string.Empty;
}
else if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
remainingPath = requestPath[route.Path.Length..];
}
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
var upstreamBase = resolvedTranslatesTo!.TrimEnd('/');
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}{context.Request.QueryString}");
var client = _httpClientFactory.CreateClient("RouteDispatch");
@@ -279,15 +287,34 @@ public sealed class RouteDispatchMiddleware
}
}
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route)
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, route);
// If regex route with capture groups, resolve $1/$2/etc. in TranslatesTo
var effectiveRoute = route;
if (regexMatch is not null && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
if (!string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal))
{
effectiveRoute = new StellaOpsRoute
{
Type = route.Type,
Path = route.Path,
IsRegex = route.IsRegex,
TranslatesTo = resolvedTranslatesTo,
DefaultTimeout = route.DefaultTimeout,
PreserveAuthHeaders = route.PreserveAuthHeaders
};
}
}
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, effectiveRoute);
if (!string.Equals(translatedPath, context.Request.Path.Value, StringComparison.Ordinal))
{
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = translatedPath;
}
var targetMicroservice = ResolveRouteTargetMicroservice(route);
var targetMicroservice = ResolveRouteTargetMicroservice(effectiveRoute);
if (!string.IsNullOrWhiteSpace(targetMicroservice))
{
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = targetMicroservice;
@@ -300,6 +327,22 @@ public sealed class RouteDispatchMiddleware
}
}
private static string? ResolveCaptureGroups(string? translatesTo, Match? regexMatch)
{
if (regexMatch is null || string.IsNullOrWhiteSpace(translatesTo))
{
return translatesTo;
}
var resolved = translatesTo;
for (var i = regexMatch.Groups.Count - 1; i >= 1; i--)
{
resolved = resolved.Replace($"${i}", regexMatch.Groups[i].Value);
}
return resolved;
}
private static string ResolveTranslatedMicroservicePath(string? requestPathValue, StellaOpsRoute route)
{
var requestPath = string.IsNullOrWhiteSpace(requestPathValue) ? "/" : requestPathValue!;
@@ -314,12 +357,18 @@ public sealed class RouteDispatchMiddleware
return requestPath;
}
// For regex routes, the TranslatesTo (after capture group substitution)
// already contains the full target path. Use it directly.
if (route.IsRegex)
{
return NormalizePath(targetPrefix);
}
var normalizedRoutePath = NormalizePath(route.Path);
var normalizedRequestPath = NormalizePath(requestPath);
var remainingPath = normalizedRequestPath;
if (!route.IsRegex &&
normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
if (normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
{
remainingPath = normalizedRequestPath[normalizedRoutePath.Length..];
if (!remainingPath.StartsWith('/'))

View File

@@ -74,6 +74,7 @@ builder.Services.Replace(ServiceDescriptor.Singleton<StellaOps.Router.Gateway.Au
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
builder.Services.AddSingleton<GatewayReadinessEvaluator>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
@@ -128,7 +129,7 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer,
IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)),
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Where(r => r.PreserveAuthHeaders && !r.IsRegex)
.Select(r => r.Path)
.ToList(),
ApprovedAuthPassthroughPrefixes = [.. bootstrapOptions.Auth.ApprovedAuthPassthroughPrefixes],

View File

@@ -27,7 +27,7 @@ public sealed class StellaOpsRouteResolver
}
}
public StellaOpsRoute? Resolve(PathString path)
public (StellaOpsRoute? Route, Match? RegexMatch) Resolve(PathString path)
{
var pathValue = path.Value ?? string.Empty;
@@ -35,9 +35,10 @@ public sealed class StellaOpsRouteResolver
{
if (pattern is not null)
{
if (pattern.IsMatch(pathValue))
var match = pattern.Match(pathValue);
if (match.Success)
{
return route;
return (route, match);
}
}
else
@@ -47,12 +48,12 @@ public sealed class StellaOpsRouteResolver
pathValue.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase) &&
route.Path.EndsWith('/'))
{
return route;
return (route, null);
}
}
}
return null;
return (null, null);
}
/// <summary>

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Gateway.WebService.Services;
public sealed record GatewayReadinessReport
{
public required bool IsReady { get; init; }
public required bool Started { get; init; }
public required bool TransportReady { get; init; }
public IReadOnlyList<string> RequiredMicroservices { get; init; } = [];
public IReadOnlyList<string> MissingMicroservices { get; init; } = [];
}
public sealed class GatewayReadinessEvaluator
{
private readonly IGlobalRoutingState _routingState;
private readonly IOptions<GatewayOptions> _options;
public GatewayReadinessEvaluator(IGlobalRoutingState routingState, IOptions<GatewayOptions> options)
{
_routingState = routingState;
_options = options;
}
public GatewayReadinessReport Evaluate(GatewayServiceStatus status)
{
ArgumentNullException.ThrowIfNull(status);
var requiredMicroservices = NormalizeRequiredMicroservices(_options.Value.Health.RequiredMicroservices);
if (!status.IsStarted || !status.IsReady)
{
return new GatewayReadinessReport
{
IsReady = false,
Started = status.IsStarted,
TransportReady = status.IsReady,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = requiredMicroservices
};
}
if (requiredMicroservices.Length == 0)
{
return new GatewayReadinessReport
{
IsReady = true,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = []
};
}
var registeredServices = _routingState.GetAllConnections()
.Where(connection => connection.Status is InstanceHealthStatus.Healthy or InstanceHealthStatus.Degraded)
.Select(connection => NormalizeServiceKey(connection.Instance.ServiceName))
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var missing = requiredMicroservices
.Where(required => !registeredServices.Any(registered => IsServiceMatch(registered, required)))
.ToArray();
return new GatewayReadinessReport
{
IsReady = missing.Length == 0,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = missing
};
}
private static string[] NormalizeRequiredMicroservices(IEnumerable<string>? services)
{
return (services ?? [])
.Select(NormalizeServiceKey)
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()!;
}
private static bool IsServiceMatch(string? registeredServiceName, string requiredServiceName)
{
var normalizedRegistered = NormalizeServiceKey(registeredServiceName);
if (string.IsNullOrWhiteSpace(normalizedRegistered))
{
return false;
}
return string.Equals(normalizedRegistered, requiredServiceName, StringComparison.OrdinalIgnoreCase);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
var portSeparator = normalized.IndexOf(':');
if (portSeparator >= 0)
{
normalized = normalized[..portSeparator];
}
const string localDomain = ".stella-ops.local";
if (normalized.EndsWith(localDomain, StringComparison.Ordinal))
{
normalized = normalized[..^localDomain.Length];
}
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
}

View File

@@ -71,125 +71,91 @@
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
"CheckInterval": "5s",
"RequiredMicroservices": []
},
"Routes": [
{ "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" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs" },
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/policy/simulations", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy/shadow", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "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://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 },
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/integrations", "TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority/quotas", "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "http://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
{ "Type": "ReverseProxy", "Path": "/api/v1/search", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },
{ "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" },
{ "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" },
{ "Type": "ReverseProxy", "Path": "/api/v1/ops/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles" },
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
{ "Type": "ReverseProxy", "Path": "/api/v1/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/secrets", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources" },
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses" },
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/gateway/rate-limits", "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
{ "Type": "ReverseProxy", "Path": "/api/admin/plans", "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" },
{ "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" },
{ "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/secrets(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sources(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/witnesses(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses$1" },
{ "Type": "Microservice", "Path": "^/api/v1/trust(.*)", "IsRegex": true, "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust$1" },
{ "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals$1" },
{ "Type": "Microservice", "Path": "^/api/v1/attestations(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sbom(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom$1" },
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
{ "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" },
{ "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" },
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
{ "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/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" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer$1" },
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/vex$1" },
{ "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": "^/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" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "Microservice", "Path": "^/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
{ "Type": "Microservice", "Path": "^/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/v1/audit-bundles(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles$1" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local/connect" },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known" },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/rekor", "TranslatesTo": "http://rekor.stella-ops.local:3322" },
{ "Type": "ReverseProxy", "Path": "/platform/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/api/v1/setup", "TranslatesTo": "http://platform.stella-ops.local/api/v1/setup" },
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyEngine", "TranslatesTo": "http://policy-engine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/concelier", "TranslatesTo": "http://concelier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/attestor", "TranslatesTo": "http://attestor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notify", "TranslatesTo": "http://notify.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notifier", "TranslatesTo": "http://notifier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/scheduler", "TranslatesTo": "http://scheduler.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/integrations", "TranslatesTo": "http://integrations.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/replay", "TranslatesTo": "http://replay.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/exportcenter", "TranslatesTo": "http://exportcenter.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/evidencelocker", "TranslatesTo": "http://evidencelocker.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signer", "TranslatesTo": "http://signer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/riskengine", "TranslatesTo": "http://riskengine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vulnexplorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/symbols", "TranslatesTo": "http://symbols.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/packsregistry", "TranslatesTo": "http://packsregistry.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/registryTokenservice", "TranslatesTo": "http://registry-token.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }

View File

@@ -31,14 +31,6 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
private int _inFlightRequestCount;
private double _errorRate;
private int _heartbeatCount;
/// <summary>
/// Number of heartbeats between periodic re-registration (HELLO re-send).
/// Ensures the gateway picks up the service after a gateway restart.
/// Default: every 30 heartbeats (~5 min at 10s intervals).
/// </summary>
private const int ReRegistrationInterval = 30;
/// <inheritdoc />
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
@@ -136,21 +128,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
// Listen for transport death to trigger automatic reconnection
_microserviceTransport.TransportDied += OnTransportDied;
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
await SendRegistrationRefreshAsync(cancellationToken);
}
else
{
@@ -330,20 +308,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
if (_microserviceTransport is null || _endpoints is null) return;
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
_cts.Token);
await SendRegistrationRefreshAsync(_cts.Token);
_logger.LogInformation(
"Messaging transport reconnected for {ServiceName}/{Version}",
@@ -361,57 +326,52 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
{
var nextHeartbeatDueUtc = DateTime.UtcNow + _options.HeartbeatInterval;
var nextRegistrationRefreshDueUtc = DateTime.UtcNow + _options.RegistrationRefreshInterval;
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
var nowUtc = DateTime.UtcNow;
var delay = Min(nextHeartbeatDueUtc, nextRegistrationRefreshDueUtc) - nowUtc;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken);
nowUtc = DateTime.UtcNow;
}
_heartbeatCount++;
// Periodically re-send HELLO to handle gateway restarts.
// The gateway loses all connection state on restart, and services
// only send HELLO once on initial connect. This ensures recovery.
if (_heartbeatCount % ReRegistrationInterval == 0 && _microserviceTransport is not null && _endpoints is not null)
if (_microserviceTransport is not null &&
_endpoints is not null &&
nowUtc >= nextRegistrationRefreshDueUtc)
{
try
{
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
_logger.LogDebug("Periodic re-registration sent (heartbeat #{Count})", _heartbeatCount);
await SendRegistrationRefreshAsync(cancellationToken);
_logger.LogDebug(
"Sent periodic HELLO refresh for {ServiceName}/{Version}",
_options.ServiceName,
_options.Version);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send periodic re-registration");
_logger.LogWarning(ex, "Failed to send periodic HELLO refresh");
}
nextRegistrationRefreshDueUtc = nowUtc + _options.RegistrationRefreshInterval;
}
// Build heartbeat payload with current status and metrics
var heartbeat = new HeartbeatPayload
if (_microserviceTransport is not null && nowUtc >= nextHeartbeatDueUtc)
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = DateTime.UtcNow
};
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = nowUtc
};
// Send heartbeat via transport
if (_microserviceTransport is not null)
{
try
{
await _microserviceTransport.SendHeartbeatAsync(heartbeat, cancellationToken);
@@ -426,12 +386,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
_logger.LogWarning(ex, "Failed to send heartbeat");
}
}
// Update connection state local heartbeat times
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = DateTime.UtcNow;
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = nowUtc;
}
nextHeartbeatDueUtc = nowUtc + _options.HeartbeatInterval;
}
}
catch (OperationCanceledException)
@@ -446,6 +407,34 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
}
}
private async Task SendRegistrationRefreshAsync(CancellationToken cancellationToken)
{
if (_microserviceTransport is null || _endpoints is null)
{
return;
}
await _microserviceTransport.ConnectAsync(
CreateInstanceDescriptor(),
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
}
private InstanceDescriptor CreateInstanceDescriptor()
{
return new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
}
private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
private static IReadOnlyDictionary<string, SchemaDefinition> MergeSchemaDefinitions(
IReadOnlyDictionary<string, SchemaDefinition>? generatedSchemas,
IReadOnlyDictionary<string, SchemaDefinition>? discoveredSchemas)

View File

@@ -56,6 +56,12 @@ public sealed partial class StellaMicroserviceOptions
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(45);
/// <summary>
/// Gets or sets the maximum interval between HELLO refreshes on an already-live transport.
/// Default: 10 seconds so gateway restarts converge quickly without waiting for a full heartbeat window.
/// </summary>
public TimeSpan RegistrationRefreshInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets or sets the maximum reconnect backoff.
/// Default: 1 minute.
@@ -88,6 +94,12 @@ public sealed partial class StellaMicroserviceOptions
if (Routers.Count == 0)
throw new InvalidOperationException("At least one router endpoint is required.");
if (HeartbeatInterval <= TimeSpan.Zero)
throw new InvalidOperationException("HeartbeatInterval must be positive.");
if (RegistrationRefreshInterval <= TimeSpan.Zero)
throw new InvalidOperationException("RegistrationRefreshInterval must be positive.");
foreach (var router in Routers)
{
if (string.IsNullOrWhiteSpace(router.Host))

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0387-A | DONE | Applied 2026-01-13; superseded by AUDIT-0598-A. |
| AUDIT-0598-A | DONE | Applied 2026-01-13; hotlist fixes and tests. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ROUTER-READY-004-M | DONE | 2026-03-10: Replaced the fixed 30-heartbeat HELLO replay cadence with an explicit bounded registration refresh interval so gateway restarts converge within seconds. |

View File

@@ -80,6 +80,7 @@ public static class StellaRouterExtensions
microserviceOptions.InstanceId = options.InstanceId;
microserviceOptions.ServiceDescription = options.ServiceDescription;
microserviceOptions.HeartbeatInterval = options.HeartbeatInterval;
microserviceOptions.RegistrationRefreshInterval = options.RegistrationRefreshInterval;
microserviceOptions.ReconnectBackoffInitial = options.ReconnectBackoffInitial;
microserviceOptions.ReconnectBackoffMax = options.ReconnectBackoffMax;
@@ -226,6 +227,11 @@ public static class StellaRouterExtensions
errors.Add("HeartbeatInterval must be positive");
}
if (options.RegistrationRefreshInterval <= TimeSpan.Zero)
{
errors.Add("RegistrationRefreshInterval must be positive");
}
if (errors.Count > 0)
{
throw new InvalidOperationException(

View File

@@ -44,6 +44,7 @@ public static class StellaRouterIntegrationHelper
opts.EnableStellaEndpoints = false;
opts.DefaultTimeout = TimeSpan.FromSeconds(routerOptions.DefaultTimeoutSeconds);
opts.HeartbeatInterval = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
opts.RegistrationRefreshInterval = TimeSpan.FromSeconds(routerOptions.RegistrationRefreshIntervalSeconds);
opts.AuthorizationTrustMode = routerOptions.AuthorizationTrustMode;
opts.IdentityEnvelopeSigningKey = routerOptions.IdentityEnvelopeSigningKey;
opts.IdentityEnvelopeClockSkewSeconds = routerOptions.IdentityEnvelopeClockSkewSeconds;
@@ -179,6 +180,10 @@ public static class StellaRouterIntegrationHelper
transportConfiguration,
resolvedRouterOptionsSection,
configuredTransports);
ApplyImplicitRegistrationRefreshInterval(
routerOptions,
configuration,
resolvedRouterOptionsSection);
services.TryAddStellaRouter(
serviceName,
@@ -405,6 +410,30 @@ public static class StellaRouterIntegrationHelper
routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds));
}
private static bool HasExplicitRegistrationRefreshInterval(
IConfiguration configuration,
string routerOptionsSection)
{
return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:RegistrationRefreshIntervalSeconds"]);
}
private static void ApplyImplicitRegistrationRefreshInterval(
StellaRouterOptionsBase routerOptions,
IConfiguration configuration,
string routerOptionsSection)
{
if (HasExplicitRegistrationRefreshInterval(configuration, routerOptionsSection))
{
return;
}
routerOptions.RegistrationRefreshIntervalSeconds = Math.Max(
1,
Math.Min(
routerOptions.RegistrationRefreshIntervalSeconds,
routerOptions.HeartbeatIntervalSeconds));
}
private static void CopySectionValues(
IConfigurationSection section,
IDictionary<string, string?> destination,

View File

@@ -103,6 +103,12 @@ public sealed class StellaRouterOptions
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(45);
/// <summary>
/// Maximum interval between HELLO refreshes on an already-live transport.
/// Default: 10 seconds.
/// </summary>
public TimeSpan RegistrationRefreshInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Initial reconnect backoff delay.
/// Default: 1 second.

View File

@@ -43,6 +43,12 @@ public class StellaRouterOptionsBase
/// </summary>
public int HeartbeatIntervalSeconds { get; set; } = 45;
/// <summary>
/// Maximum interval in seconds between HELLO refreshes on an already-live transport.
/// Default: 10 seconds to keep gateway restarts from leaving the frontdoor cold for minutes.
/// </summary>
public int RegistrationRefreshIntervalSeconds { get; set; } = 10;
/// <summary>
/// Service trust mode for gateway-enforced authorization semantics.
/// Default: Hybrid.

View File

@@ -34,5 +34,5 @@ public sealed class StellaOpsRoute
/// of stripping them. Use for upstream services that perform their own JWT
/// validation (e.g., Authority admin API).
/// </summary>
public bool PreserveAuthHeaders { get; set; }
public bool PreserveAuthHeaders { get; set; } = true;
}

View File

@@ -1,5 +1,6 @@
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Gateway.Middleware;
@@ -36,18 +37,36 @@ public sealed class EndpointResolutionMiddleware
? targetService as string
: null;
EndpointDescriptor? endpoint;
EndpointResolutionResult resolution;
if (!string.IsNullOrWhiteSpace(targetMicroserviceHint))
{
endpoint = ResolveEndpointForTargetService(routingState, method, path, targetMicroserviceHint!);
resolution = ResolveEndpointForTargetService(routingState, method, path, targetMicroserviceHint!);
}
else
{
endpoint = routingState.ResolveEndpoint(method, path);
resolution = new EndpointResolutionResult(routingState.ResolveEndpoint(method, path), false);
}
var endpoint = resolution.Endpoint;
if (endpoint is null)
{
if (!string.IsNullOrWhiteSpace(targetMicroserviceHint) && !resolution.ServiceRegistered)
{
await RouterErrorWriter.WriteAsync(
context,
statusCode: StatusCodes.Status503ServiceUnavailable,
error: "Target microservice unavailable",
message: "The configured microservice route exists, but the target service has not registered with the gateway yet.",
service: targetMicroserviceHint,
details: new Dictionary<string, object?>
{
["translatedPath"] = path,
["targetMicroservice"] = targetMicroserviceHint
},
cancellationToken: context.RequestAborted);
return;
}
await RouterErrorWriter.WriteAsync(
context,
statusCode: StatusCodes.Status404NotFound,
@@ -62,85 +81,108 @@ public sealed class EndpointResolutionMiddleware
await _next(context);
}
private static EndpointDescriptor? ResolveEndpointForTargetService(
private static EndpointResolutionResult ResolveEndpointForTargetService(
IGlobalRoutingState routingState,
string method,
string path,
string targetServiceHint)
{
var normalizedHint = NormalizeServiceKey(targetServiceHint);
if (string.IsNullOrWhiteSpace(normalizedHint))
var exactHint = NormalizeServiceKey(targetServiceHint, preserveGatewaySuffix: true);
if (string.IsNullOrWhiteSpace(exactHint))
{
return null;
return new EndpointResolutionResult(null, false);
}
EndpointDescriptor? bestEndpoint = null;
var bestScore = int.MinValue;
var aliasHint = NormalizeServiceKey(targetServiceHint);
var allowAliasMatching = !IsExplicitServiceHint(exactHint);
var candidates = new List<(EndpointDescriptor Endpoint, ConnectionState Connection, int ServiceScore)>();
var serviceRegistered = false;
foreach (var connection in routingState.GetAllConnections())
{
var serviceScore = GetServiceMatchScore(connection.Instance.ServiceName, normalizedHint);
var serviceScore = GetServiceMatchScore(connection.Instance.ServiceName, exactHint, aliasHint, allowAliasMatching);
if (serviceScore < 0)
{
continue;
}
foreach (var endpoint in connection.Endpoints.Values)
serviceRegistered = true;
foreach (var candidateEndpoint in connection.Endpoints.Values)
{
if (!string.Equals(endpoint.Method, method, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(candidateEndpoint.Method, method, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var matcher = new PathMatcher(endpoint.Path);
var matcher = new PathMatcher(candidateEndpoint.Path);
if (!matcher.IsMatch(path))
{
continue;
}
var endpointScore = (serviceScore * 1000) + endpoint.Path.Length;
if (endpointScore <= bestScore)
{
continue;
}
bestEndpoint = endpoint;
bestScore = endpointScore;
candidates.Add((candidateEndpoint, connection, serviceScore));
}
}
return bestEndpoint;
var selectedEndpoint = candidates
.OrderByDescending(candidate => candidate.ServiceScore)
.ThenByDescending(candidate => candidate.Endpoint.Path.Length)
.ThenByDescending(candidate => GetHealthRank(candidate.Connection.Status))
.ThenByDescending(candidate => candidate.Connection.LastHeartbeatUtc)
.ThenBy(candidate => candidate.Connection.ConnectionId, StringComparer.Ordinal)
.Select(candidate => candidate.Endpoint)
.FirstOrDefault();
return new EndpointResolutionResult(selectedEndpoint, serviceRegistered);
}
private static int GetServiceMatchScore(string? serviceName, string normalizedHint)
private static int GetServiceMatchScore(
string? serviceName,
string exactHint,
string? aliasHint,
bool allowAliasMatching)
{
var normalizedService = NormalizeServiceKey(serviceName);
if (string.IsNullOrWhiteSpace(normalizedService))
var exactService = NormalizeServiceKey(serviceName, preserveGatewaySuffix: true);
if (string.IsNullOrWhiteSpace(exactService))
{
return -1;
}
if (string.Equals(serviceName, normalizedHint, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedService, normalizedHint, StringComparison.OrdinalIgnoreCase))
if (string.Equals(serviceName, exactHint, StringComparison.OrdinalIgnoreCase) ||
string.Equals(exactService, exactHint, StringComparison.OrdinalIgnoreCase))
{
return 6;
}
var aliasService = NormalizeServiceKey(serviceName);
if (!string.IsNullOrWhiteSpace(aliasHint) &&
string.Equals(aliasService, aliasHint, StringComparison.OrdinalIgnoreCase))
{
return 5;
}
if (serviceName is not null &&
(serviceName.StartsWith(normalizedHint + "-", StringComparison.OrdinalIgnoreCase) ||
serviceName.StartsWith(normalizedHint + "_", StringComparison.OrdinalIgnoreCase)))
if (!allowAliasMatching || string.IsNullOrWhiteSpace(aliasHint))
{
return -1;
}
if (exactService.StartsWith(aliasHint + "-", StringComparison.OrdinalIgnoreCase) ||
exactService.StartsWith(aliasHint + "_", StringComparison.OrdinalIgnoreCase))
{
return 4;
}
if (normalizedService.StartsWith(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.StartsWith(normalizedService, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(aliasService) &&
(aliasService.StartsWith(aliasHint, StringComparison.OrdinalIgnoreCase) ||
aliasHint.StartsWith(aliasService, StringComparison.OrdinalIgnoreCase)))
{
return 3;
}
if (normalizedService.Contains(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.Contains(normalizedService, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(aliasService) &&
(aliasService.Contains(aliasHint, StringComparison.OrdinalIgnoreCase) ||
aliasHint.Contains(aliasService, StringComparison.OrdinalIgnoreCase)))
{
return 2;
}
@@ -148,7 +190,7 @@ public sealed class EndpointResolutionMiddleware
return -1;
}
private static string? NormalizeServiceKey(string? value)
private static string? NormalizeServiceKey(string? value, bool preserveGatewaySuffix = false)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -172,17 +214,42 @@ public sealed class EndpointResolutionMiddleware
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
if (!preserveGatewaySuffix)
{
normalized = StripSuffix(normalized, "-gateway");
}
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static bool IsExplicitServiceHint(string? hint)
{
if (string.IsNullOrWhiteSpace(hint))
{
return false;
}
return hint.Contains('-', StringComparison.Ordinal) ||
hint.Contains('_', StringComparison.Ordinal);
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
private static int GetHealthRank(InstanceHealthStatus status) => status switch
{
InstanceHealthStatus.Healthy => 4,
InstanceHealthStatus.Degraded => 3,
InstanceHealthStatus.Unknown => 2,
InstanceHealthStatus.Draining => 1,
_ => 0
};
private sealed record EndpointResolutionResult(EndpointDescriptor? Endpoint, bool ServiceRegistered);
}

View File

@@ -1,6 +1,7 @@
using StellaOps.Router.Common;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using System.Collections.Concurrent;
@@ -85,7 +86,8 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
/// <inheritdoc />
public EndpointDescriptor? ResolveEndpoint(string method, string path)
{
// First try exact match
var matches = new List<(EndpointDescriptor Endpoint, ConnectionState Connection)>();
foreach (var ((m, p), matcher) in _pathMatchers)
{
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
@@ -101,14 +103,20 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
if (_connections.TryGetValue(connectionId, out var conn) &&
conn.Endpoints.TryGetValue((m, p), out var endpoint))
{
return endpoint;
matches.Add((endpoint, conn));
}
}
}
}
}
return null;
return matches
.OrderByDescending(match => match.Endpoint.Path.Length)
.ThenByDescending(match => GetHealthRank(match.Connection.Status))
.ThenByDescending(match => match.Connection.LastHeartbeatUtc)
.ThenBy(match => match.Connection.ConnectionId, StringComparer.Ordinal)
.Select(match => match.Endpoint)
.FirstOrDefault();
}
/// <inheritdoc />
@@ -194,4 +202,13 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
string.Equals(existing.Instance.InstanceId, candidate.Instance.InstanceId, StringComparison.Ordinal) &&
string.Equals(existing.Instance.Region, candidate.Instance.Region, StringComparison.OrdinalIgnoreCase);
}
private static int GetHealthRank(InstanceHealthStatus status) => status switch
{
InstanceHealthStatus.Healthy => 4,
InstanceHealthStatus.Degraded => 3,
InstanceHealthStatus.Unknown => 2,
InstanceHealthStatus.Draining => 1,
_ => 0
};
}

View File

@@ -353,6 +353,56 @@ public sealed class GatewayOptionsValidatorTests
Assert.Null(exception);
}
[Fact]
public void Validate_RegexWithValidCaptureGroupRefs_DoesNotThrow()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
});
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
Assert.Null(exception);
}
[Fact]
public void Validate_RegexWithInvalidCaptureGroupRef_Throws()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/$2"
});
var exception = Assert.Throws<InvalidOperationException>(() =>
GatewayOptionsValidator.Validate(options));
Assert.Contains("$2", exception.Message, StringComparison.Ordinal);
Assert.Contains("capture group", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_RegexWithNoTranslatesTo_DoesNotThrow()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true
});
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
Assert.Null(exception);
}
[Theory]
[InlineData(null)]
[InlineData("")]

View File

@@ -4,17 +4,33 @@ namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class GatewayRouteSearchMappingsTests
{
private static readonly (string Path, string Target, string RouteType)[] RequiredMappings =
private static readonly (string Path, string Target, string RouteType, bool IsRegex)[] RequiredMappings =
[
("/api/v1/search", "http://advisoryai.stella-ops.local/v1/search", "ReverseProxy"),
("/api/v1/advisory-ai", "http://advisoryai.stella-ops.local/v1/advisory-ai", "ReverseProxy")
("^/api/v1/search(.*)", "http://advisoryai.stella-ops.local/v1/search$1", "Microservice", true),
("^/api/v1/advisory-ai(.*)", "http://advisoryai.stella-ops.local/v1/advisory-ai$1", "Microservice", true),
("^/api/v1/watchlist(.*)", "http://attestor.stella-ops.local/api/v1/watchlist$1", "Microservice", true),
("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true),
("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true),
("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true),
("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true),
("^/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/integrations(.*)", "http://platform.stella-ops.local/api/v2/integrations$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)
];
private static readonly (string Path, string AppSettingsTarget, string LocalTarget)[] RequiredReverseProxyMappings =
[
("/connect", "http://authority.stella-ops.local/connect", "http://authority.stella-ops.local/connect"),
("/authority/console", "http://authority.stella-ops.local/console", "https://authority.stella-ops.local/console")
];
public static TheoryData<string> RouteConfigPaths => new()
{
"src/Router/StellaOps.Gateway.WebService/appsettings.json",
"devops/compose/router-gateway-local.json",
"devops/compose/router-gateway-local.reverseproxy.json"
"devops/compose/router-gateway-local.json"
};
[Theory]
@@ -38,17 +54,19 @@ public sealed class GatewayRouteSearchMappingsTests
route.GetProperty("Path").GetString() ?? string.Empty,
route.TryGetProperty("TranslatesTo", out var translatesTo)
? translatesTo.GetString() ?? string.Empty
: string.Empty))
: string.Empty,
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
.ToList();
var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
foreach (var (requiredPath, requiredTarget, requiredType) in RequiredMappings)
foreach (var (requiredPath, requiredTarget, requiredType, requiredIsRegex) in RequiredMappings)
{
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
Assert.Equal(requiredType, route!.Type);
Assert.Equal(requiredTarget, route!.TranslatesTo);
Assert.Equal(requiredIsRegex, route!.IsRegex);
if (catchAllIndex >= 0)
{
@@ -57,6 +75,46 @@ public sealed class GatewayRouteSearchMappingsTests
}
}
[Theory]
[MemberData(nameof(RouteConfigPaths))]
public void RouteTable_ContainsRequiredReverseProxyMappings(string configRelativePath)
{
var repoRoot = FindRepositoryRoot();
var configPath = Path.Combine(repoRoot, configRelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(configPath), $"Config file not found: {configPath}");
using var stream = File.OpenRead(configPath);
using var document = JsonDocument.Parse(stream);
var routes = document.RootElement
.GetProperty("Gateway")
.GetProperty("Routes")
.EnumerateArray()
.Select(route => new RouteEntry(
Index: -1,
route.GetProperty("Type").GetString() ?? string.Empty,
route.GetProperty("Path").GetString() ?? string.Empty,
route.TryGetProperty("TranslatesTo", out var translatesTo)
? translatesTo.GetString() ?? string.Empty
: string.Empty,
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
.ToList();
var isLocalComposeConfig = string.Equals(
configRelativePath,
"devops/compose/router-gateway-local.json",
StringComparison.Ordinal);
foreach (var (requiredPath, appSettingsTarget, localTarget) in RequiredReverseProxyMappings)
{
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
Assert.Equal("ReverseProxy", route!.Type);
Assert.Equal(isLocalComposeConfig ? localTarget : appSettingsTarget, route.TranslatesTo);
Assert.False(route.IsRegex);
}
}
private static string FindRepositoryRoot()
{
for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent)
@@ -70,5 +128,5 @@ public sealed class GatewayRouteSearchMappingsTests
throw new InvalidOperationException($"Unable to locate repository root from {AppContext.BaseDirectory}.");
}
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo);
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo, bool IsRegex);
}

View File

@@ -1,9 +1,10 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
@@ -51,6 +52,93 @@ public sealed class GatewayIntegrationTests : IClassFixture<GatewayWebApplicatio
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task HealthReady_ReturnsServiceUnavailable_WhenRequiredMicroserviceIsMissing()
{
using var factory = CreateFactoryWithRequiredServices("policy");
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
}
[Fact]
public async Task HealthReady_ReturnsOk_WhenRequiredMicroserviceIsRegistered()
{
using var factory = CreateFactoryWithRequiredServices("policy");
using (var scope = factory.Services.CreateScope())
{
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
routingState.AddConnection(new ConnectionState
{
ConnectionId = "conn-policy",
Instance = new InstanceDescriptor
{
InstanceId = "policy-01",
ServiceName = "policy-gateway",
Version = "1.0.0",
Region = "test"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.Messaging
});
}
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task HealthReady_ReturnsServiceUnavailable_WhenOnlySiblingServiceIsRegistered()
{
using var factory = CreateFactoryWithRequiredServices("policy");
using (var scope = factory.Services.CreateScope())
{
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
routingState.AddConnection(new ConnectionState
{
ConnectionId = "conn-policy-engine",
Instance = new InstanceDescriptor
{
InstanceId = "policy-engine-01",
ServiceName = "policy-engine",
Version = "1.0.0",
Region = "test"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.Messaging
});
}
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
}
private WebApplicationFactory<Program> CreateFactoryWithRequiredServices(params string[] requiredServices)
{
return new GatewayWebApplicationFactory().WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.PostConfigure<GatewayOptions>(options =>
{
options.Health.RequiredMicroservices = requiredServices.ToList();
});
});
});
}
[Fact]
public async Task OpenApiJson_ReturnsValidOpenApiDocument()
{

View File

@@ -259,6 +259,137 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
}
}
[Fact]
public async Task InvokeAsync_RegexCatchAll_CaptureGroupSubstitution_ResolvesServiceAndPath()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/scanner/health";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"scanner",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
// Path matches request: no translation needed
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
}
[Fact]
public async Task InvokeAsync_RegexAlias_CaptureGroupSubstitution_ResolvesCorrectService()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/vulnerabilities(.*)",
IsRegex = true,
TranslatesTo = "http://scanner.stella-ops.local/api/v1/vulnerabilities$1"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/vulnerabilities/cve-2024-1234";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"scanner",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
[Fact]
public async Task InvokeAsync_RegexCatchAll_WithPathRewrite_SetsTranslatedPath()
{
// Arrange: advisoryai /api/v1/search -> /v1/search (path rewrite via capture group)
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/search(.*)",
IsRegex = true,
TranslatesTo = "http://advisoryai.stella-ops.local/v1/search$1"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/search/entities";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"advisoryai",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
Assert.Equal(
"/v1/search/entities",
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
}
[Fact]
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
{

View File

@@ -29,8 +29,9 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/dashboard"));
Assert.NotNull(result);
Assert.Equal("/dashboard", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/dashboard", result.Route.Path);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -41,8 +42,9 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/app/index.html"));
Assert.NotNull(result);
Assert.Equal("/app", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/app", result.Route.Path);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -53,9 +55,28 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/api/v2/data"));
Assert.NotNull(result);
Assert.True(result.IsRegex);
Assert.Equal(@"^/api/v[0-9]+/.*", result.Path);
Assert.NotNull(result.Route);
Assert.True(result.Route.IsRegex);
Assert.Equal(@"^/api/v[0-9]+/.*", result.Route.Path);
Assert.NotNull(result.RegexMatch);
Assert.True(result.RegexMatch.Success);
}
[Fact]
public void Resolve_RegexRoute_ReturnsCaptureGroups()
{
var route = MakeRoute(
@"^/api/v1/([^/]+)(.*)",
isRegex: true,
translatesTo: "http://$1.stella-ops.local/api/v1/$1$2");
var resolver = new StellaOpsRouteResolver(new[] { route });
var result = resolver.Resolve(new PathString("/api/v1/scanner/health"));
Assert.NotNull(result.Route);
Assert.NotNull(result.RegexMatch);
Assert.Equal("scanner", result.RegexMatch.Groups[1].Value);
Assert.Equal("/health", result.RegexMatch.Groups[2].Value);
}
[Fact]
@@ -66,7 +87,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/unknown"));
Assert.Null(result);
Assert.Null(result.Route);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -78,8 +100,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/api/resource"));
Assert.NotNull(result);
Assert.Equal("http://first:5000", result.TranslatesTo);
Assert.NotNull(result.Route);
Assert.Equal("http://first:5000", result.Route.TranslatesTo);
}
[Fact]
@@ -90,7 +112,7 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/not-found"));
Assert.Null(result);
Assert.Null(result.Route);
}
[Fact]
@@ -101,7 +123,7 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/error"));
Assert.Null(result);
Assert.Null(result.Route);
}
[Fact]
@@ -112,8 +134,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/APP"));
Assert.NotNull(result);
Assert.Equal("/app", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/app", result.Route.Path);
}
[Fact]
@@ -123,6 +145,6 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/anything"));
Assert.Null(result);
Assert.Null(result.Route);
}
}

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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. |
| ROUTER-READY-001-T | DONE | 2026-03-10: Added required-service readiness coverage and truthful warm-up status contracts for router frontdoor convergence. |

View File

@@ -31,6 +31,7 @@ public sealed class RouterConnectionManagerTests : IDisposable
Region = "test",
InstanceId = "test-instance-1",
HeartbeatInterval = TimeSpan.FromMilliseconds(50),
RegistrationRefreshInterval = TimeSpan.FromMilliseconds(20),
ReconnectBackoffInitial = TimeSpan.FromMilliseconds(10),
ReconnectBackoffMax = TimeSpan.FromMilliseconds(100)
};
@@ -385,6 +386,47 @@ public sealed class RouterConnectionManagerTests : IDisposable
capturedHeartbeat.ErrorRate.Should().Be(0.05);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_ReplaysHelloWithinRegistrationRefreshInterval()
{
// Arrange
_options.Routers.Add(new RouterEndpointConfig
{
Host = "localhost",
Port = 5000,
TransportType = TransportType.InMemory
});
var registrationReplayObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var connectCount = 0;
_transportMock
.Setup(t => t.ConnectAsync(
It.IsAny<InstanceDescriptor>(),
It.IsAny<IReadOnlyList<EndpointDescriptor>>(),
It.IsAny<IReadOnlyDictionary<string, SchemaDefinition>?>(),
It.IsAny<ServiceOpenApiInfo?>(),
It.IsAny<CancellationToken>()))
.Callback(() =>
{
if (Interlocked.Increment(ref connectCount) >= 2)
{
registrationReplayObserved.TrySetResult();
}
})
.Returns(Task.CompletedTask);
using var manager = CreateManager();
// Act
await manager.StartAsync(CancellationToken.None);
await registrationReplayObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
await manager.StopAsync(CancellationToken.None);
// Assert
connectCount.Should().BeGreaterThanOrEqualTo(2);
}
#endregion
#region Dispose Tests

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0393-T | DONE | Revalidated 2026-01-07; test coverage audit for Router StellaOps.Microservice.Tests. |
| AUDIT-0393-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ROUTER-READY-004-T | DONE | 2026-03-10: Added deterministic coverage for bounded HELLO replay cadence after gateway restarts. |

View File

@@ -205,6 +205,7 @@ public sealed class StellaRouterIntegrationHelperTests
["TimelineIndexer:Router:Messaging:RequestTimeout"] = "45s",
["TimelineIndexer:Router:Messaging:LeaseDuration"] = "4m",
["TimelineIndexer:Router:Messaging:HeartbeatInterval"] = "12s",
["TimelineIndexer:Router:RegistrationRefreshIntervalSeconds"] = "7",
["TimelineIndexer:Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
["TimelineIndexer:Router:Messaging:valkey:Database"] = "2"
});
@@ -223,6 +224,7 @@ public sealed class StellaRouterIntegrationHelperTests
Assert.True(result);
Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
Assert.Equal(TimeSpan.FromSeconds(7), options.RegistrationRefreshInterval);
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
Assert.Equal("router:responses", messaging.ResponseQueueName);
Assert.Equal("timelineindexer", messaging.ConsumerGroup);

View File

@@ -2,6 +2,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Middleware;
@@ -103,7 +104,7 @@ public sealed class EndpointResolutionMiddlewareTests
}
[Fact]
public async Task Invoke_DoesNotFallbackToDifferentService_WhenTargetHintHasNoMatch()
public async Task Invoke_Returns503_WhenTargetServiceIsNotRegistered()
{
// Arrange
var context = new DefaultHttpContext();
@@ -144,12 +145,91 @@ public sealed class EndpointResolutionMiddlewareTests
// Assert
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_Returns503_WhenSpecificGatewayHintWouldOtherwiseMatchSiblingService()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy-gateway";
var siblingEndpoint = new EndpointDescriptor
{
ServiceName = "policy-engine",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/policy/__router_smoke__"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy-engine", "policy-engine", siblingEndpoint)
]);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState.Object);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_Returns404_WhenTargetServiceIsRegisteredButEndpointDoesNotExist()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/governance/not-real";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/not-real";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
var otherEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy", "policy-gateway", otherEndpoint)
]);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState.Object);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
public async Task Invoke_MatchesRouteHintWithServicePrefixAlias()
{
@@ -186,10 +266,55 @@ public sealed class EndpointResolutionMiddlewareTests
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("findings-ledger");
}
[Fact]
public async Task Invoke_WhenMultipleMatchingConnectionsExist_PrefersHealthyMostRecentEndpoint()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/governance/staleness/config";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/staleness/config";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
var staleEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var healthyEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.1",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-stale", "policy-gateway", staleEndpoint, InstanceHealthStatus.Unhealthy, new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc)),
CreateConnection("conn-healthy", "policy-gateway", healthyEndpoint, InstanceHealthStatus.Healthy, new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc))
]);
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
await middleware.Invoke(context, routingState.Object);
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(healthyEndpoint);
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("policy-gateway");
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
EndpointDescriptor endpoint)
EndpointDescriptor endpoint,
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
DateTime? lastHeartbeatUtc = null)
{
var instance = new InstanceDescriptor
{
@@ -203,6 +328,8 @@ public sealed class EndpointResolutionMiddlewareTests
{
ConnectionId = connectionId,
Instance = instance,
Status = status,
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
TransportType = StellaOps.Router.Common.Enums.TransportType.Messaging
};

View File

@@ -54,15 +54,46 @@ public sealed class InMemoryRoutingStateTests
.Which.ConnectionId.Should().Be("conn-1");
}
[Fact]
public void ResolveEndpoint_WhenSamePathExistsAcrossVersions_PrefersHealthyMostRecentRegistration()
{
var state = new InMemoryRoutingState();
var staleConnection = CreateConnection(
connectionId: "conn-stale",
instanceId: "policy-gateway-old",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.0",
status: InstanceHealthStatus.Unhealthy,
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc));
var healthyConnection = CreateConnection(
connectionId: "conn-healthy",
instanceId: "policy-gateway-new",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.1",
status: InstanceHealthStatus.Healthy,
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc));
state.AddConnection(staleConnection);
state.AddConnection(healthyConnection);
var resolved = state.ResolveEndpoint("GET", "/api/v1/governance/staleness/config");
resolved.Should().NotBeNull();
resolved!.Version.Should().Be("1.0.1");
}
private static ConnectionState CreateConnection(
string connectionId,
string instanceId,
string endpointPath)
string endpointPath,
string version = "1.0.0",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
DateTime? lastHeartbeatUtc = null)
{
var endpoint = new EndpointDescriptor
{
ServiceName = "integrations",
Version = "1.0.0",
Version = version,
Method = "GET",
Path = endpointPath
};
@@ -73,10 +104,12 @@ public sealed class InMemoryRoutingStateTests
Instance = new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = "integrations",
Version = "1.0.0",
ServiceName = endpointPath.Contains("/governance/", StringComparison.Ordinal) ? "policy-gateway" : "integrations",
Version = version,
Region = "local"
},
Status = status,
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
TransportType = TransportType.Messaging
};