diff --git a/devops/compose/README.md b/devops/compose/README.md index 86e99891a..1abbe68cf 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -138,6 +138,9 @@ 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 +pwsh ./scripts/router-mode-redeploy.ps1 -Mode microservice ``` Validation endpoints: @@ -148,6 +151,9 @@ curl -k https://127.1.0.1/openapi.json # Timeline API schema (through router-gateway) curl -k https://127.1.0.1/openapi.json | jq '.paths["/api/v1/timeline"]' + +# Header search routing smoke (fails on missing /api/v1/search* or /api/v1/advisory-ai/search* routes) +pwsh ./scripts/header-search-smoke.ps1 ``` ### With Observability diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 465d9facf..1f024fd8c 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -347,10 +347,10 @@ services: Logging__LogLevel__Microsoft.AspNetCore.Authorization: "Debug" Platform__Storage__Driver: "postgres" Platform__Storage__PostgresConnectionString: *postgres-connection - Platform__EnvironmentSettings__AuthorizeEndpoint: "https://127.1.0.1/connect/authorize" - Platform__EnvironmentSettings__TokenEndpoint: "https://127.1.0.1/connect/token" - Platform__EnvironmentSettings__RedirectUri: "https://127.1.0.1/auth/callback" - Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://127.1.0.1/" + Platform__EnvironmentSettings__AuthorizeEndpoint: "https://stella-ops.local/connect/authorize" + Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token" + Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" + Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write" STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" @@ -1190,8 +1190,8 @@ services: AirGap__Egress__Enabled: "false" volumes: - *cert-volume - - taskrunner-artifacts-data:/app/artifacts tmpfs: + - /app/artifacts:mode=1777 - /app/queue:mode=1777 - /app/state:mode=1777 - /app/approvals:mode=1777 @@ -2208,6 +2208,9 @@ services: ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ADVISORYAI__KnowledgeSearch__ConnectionString: *postgres-connection + ADVISORYAI__KnowledgeSearch__FindingsAdapterEnabled: "true" + ADVISORYAI__KnowledgeSearch__FindingsAdapterBaseUrl: "http://scanner.stella-ops.local" Router__Enabled: "${ADVISORYAI_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "advisoryai" ports: @@ -2243,6 +2246,9 @@ services: ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}" ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}" ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}" + ADVISORYAI__KnowledgeSearch__ConnectionString: *postgres-connection + ADVISORYAI__KnowledgeSearch__FindingsAdapterEnabled: "true" + ADVISORYAI__KnowledgeSearch__FindingsAdapterBaseUrl: "http://scanner.stella-ops.local" volumes: - *cert-volume networks: diff --git a/devops/compose/envsettings-override.json b/devops/compose/envsettings-override.json index b0ef1e7fd..af59c2107 100644 --- a/devops/compose/envsettings-override.json +++ b/devops/compose/envsettings-override.json @@ -2,10 +2,10 @@ "authority": { "issuer": "https://authority.stella-ops.local/", "clientId": "stella-ops-ui", - "authorizeEndpoint": "https://127.1.0.1/connect/authorize", - "tokenEndpoint": "https://127.1.0.1/connect/token", - "redirectUri": "https://127.1.0.1/auth/callback", - "postLogoutRedirectUri": "https://127.1.0.1/", + "authorizeEndpoint": "https://stella-ops.local/connect/authorize", + "tokenEndpoint": "https://stella-ops.local/connect/token", + "redirectUri": "https://stella-ops.local/auth/callback", + "postLogoutRedirectUri": "https://stella-ops.local/", "scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write", "audience": "stella-ops-api", "dpopAlgorithms": [ diff --git a/devops/compose/postgres-init/13-platform-translations.sql b/devops/compose/postgres-init/13-platform-translations.sql new file mode 100644 index 000000000..bed094f23 --- /dev/null +++ b/devops/compose/postgres-init/13-platform-translations.sql @@ -0,0 +1,18 @@ +-- Ensure Platform localization DB overrides table exists for /platform/i18n. +-- This is idempotent and safe to run on new compose databases. + +CREATE SCHEMA IF NOT EXISTS platform; + +CREATE TABLE IF NOT EXISTS platform.translations ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + locale VARCHAR(10) NOT NULL, + key VARCHAR(512) NOT NULL, + value TEXT NOT NULL, + tenant_id VARCHAR(128) NOT NULL DEFAULT '_system', + updated_by VARCHAR(256) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ux_translations_tenant_locale_key UNIQUE (tenant_id, locale, key) +); + +CREATE INDEX IF NOT EXISTS ix_translations_tenant_locale + ON platform.translations (tenant_id, locale); diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index b09819fce..f83c703dd 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -220,15 +220,27 @@ "PreserveAuthHeaders": true }, { - "Type": "Microservice", + "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai/adapters", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters", "PreserveAuthHeaders": true }, { - "Type": "Microservice", + "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/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 }, { diff --git a/devops/compose/router-gateway-local.reverseproxy.json b/devops/compose/router-gateway-local.reverseproxy.json index f762be34d..668e2addb 100644 --- a/devops/compose/router-gateway-local.reverseproxy.json +++ b/devops/compose/router-gateway-local.reverseproxy.json @@ -184,10 +184,16 @@ "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/api/v1/advisory-ai", + "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai", "PreserveAuthHeaders": true }, { diff --git a/devops/compose/scripts/header-search-smoke.ps1 b/devops/compose/scripts/header-search-smoke.ps1 new file mode 100644 index 000000000..52f4fca10 --- /dev/null +++ b/devops/compose/scripts/header-search-smoke.ps1 @@ -0,0 +1,284 @@ +param( + [string]$GatewayBaseUrl = "https://stella-ops.local", + [string]$AdvisoryAiBaseUrl = "http://advisoryai.stella-ops.local", + [string]$Tenant = "stellaops", + [int]$TimeoutSeconds = 20, + [switch]$SkipUiResponsivenessProbe +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Invoke-Probe { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Path, + [string]$Body + ) + + $headersFile = [System.IO.Path]::GetTempFileName() + $bodyFile = [System.IO.Path]::GetTempFileName() + try { + $curlArgs = @( + "-k" + "-sS" + "--noproxy" + "*" + "-D" + $headersFile + "-o" + $bodyFile + "-X" + $Method + "--max-time" + $TimeoutSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture) + "-H" + "Accept: application/json" + "-H" + "X-Tenant: $Tenant" + "-H" + "X-StellaOps-Tenant: $Tenant" + "-H" + "X-Stella-Tenant: $Tenant" + ) + + if (-not [string]::IsNullOrWhiteSpace($Body)) { + $curlArgs += @( + "-H" + "Content-Type: application/json" + "--data-raw" + $Body + ) + } + + $url = "$GatewayBaseUrl$Path" + $curlArgs += @("-w", "%{http_code}", $url) + + $statusText = (& curl.exe @curlArgs).Trim() + if ($LASTEXITCODE -ne 0) { + throw "curl failed for $Name ($Method $Path) with exit code $LASTEXITCODE." + } + + if ($statusText -notmatch "^\d{3}$") { + throw "Unable to parse HTTP status for $Name ($Method $Path). Raw status text: '$statusText'." + } + + $statusCode = [int]$statusText + $contentTypeLine = Get-Content -LiteralPath $headersFile | + Where-Object { $_ -match "^[Cc]ontent-[Tt]ype:" } | + Select-Object -Last 1 + $contentTypeLine = [string]$contentTypeLine + $contentType = if ([string]::IsNullOrWhiteSpace($contentTypeLine)) { + "" + } else { + (($contentTypeLine -replace "^[Cc]ontent-[Tt]ype:\s*", "") -replace "\r", "").Trim() + } + + $responseBody = [string](Get-Content -LiteralPath $bodyFile -Raw -ErrorAction SilentlyContinue) + if ($null -eq $responseBody) { + $responseBody = "" + } + if ($responseBody.Length -gt 800) { + $responseBody = $responseBody.Substring(0, 800) + } + + return [pscustomobject]@{ + Name = $Name + Method = $Method + Path = $Path + Url = $url + StatusCode = $statusCode + ContentType = $contentType + BodyPreview = $responseBody + } + } + finally { + Remove-Item -LiteralPath $headersFile -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $bodyFile -ErrorAction SilentlyContinue + } +} + +function Invoke-UiResponsivenessProbe { + param( + [Parameter(Mandatory = $true)] + [string]$BaseUrl + ) + + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..") + $webNodeModules = Join-Path $repoRoot "src\\Web\\StellaOps.Web\\node_modules" + if (-not (Test-Path -LiteralPath $webNodeModules)) { + throw "UI responsiveness probe requires $webNodeModules. Install web dependencies first." + } + + $probeScriptPath = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), ".cjs") + $probeScript = @' +const { chromium } = require("playwright"); + +const targetUrl = process.argv[2]; +if (!targetUrl) { + throw new Error("Missing target URL argument."); +} + +const evaluateWithTimeout = async (page, timeoutMs) => { + return Promise.race([ + page.evaluate(() => ({ + readyState: document.readyState, + title: document.title, + location: window.location.href, + appRootPresent: !!document.querySelector("app-root"), + globalSearchInputCount: document.querySelectorAll('app-global-search input[type="text"]').length, + })), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`ui-evaluate-timeout-${timeoutMs}ms`)), timeoutMs); + }), + ]); +}; + +(async () => { + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); + try { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(3000); + const probe = await evaluateWithTimeout(page, 10000); + console.log(JSON.stringify(probe)); + } finally { + await browser.close(); + } +})().catch((error) => { + console.error(error?.stack ?? String(error)); + process.exit(1); +}); +'@ + + $originalNodePath = $env:NODE_PATH + try { + Set-Content -LiteralPath $probeScriptPath -Value $probeScript -Encoding UTF8 + $env:NODE_PATH = $webNodeModules + $probeOutput = (& node $probeScriptPath $BaseUrl 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "UI responsiveness probe failed for $BaseUrl. Output: $probeOutput" + } + + $probeJsonLine = [string]($probeOutput | Select-Object -Last 1) + $probe = $probeJsonLine | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($probe.title)) { + throw "UI responsiveness probe returned an empty document title for $BaseUrl." + } + + Write-Host "[OK] UI responsiveness probe: title='$($probe.title)' readyState=$($probe.readyState) url=$($probe.location)" + } + finally { + Remove-Item -LiteralPath $probeScriptPath -ErrorAction SilentlyContinue + if ($null -eq $originalNodePath) { + Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue + } else { + $env:NODE_PATH = $originalNodePath + } + } +} + +$rebuildProbeHeaders = @( + "-H", "Accept: application/json", + "-H", "Content-Type: application/json", + "-H", "X-StellaOps-Tenant: $Tenant", + "-H", "X-StellaOps-Scopes: advisory-ai:admin advisory-ai:operate advisory-ai:view", + "-H", "X-StellaOps-Actor: header-search-smoke" +) + +$rebuildHeadersFile = [System.IO.Path]::GetTempFileName() +$rebuildBodyFile = [System.IO.Path]::GetTempFileName() +try { + $rebuildStatus = (& curl.exe -sS --noproxy "*" -D $rebuildHeadersFile -o $rebuildBodyFile -X POST --max-time $TimeoutSeconds @rebuildProbeHeaders -w "%{http_code}" "$AdvisoryAiBaseUrl/v1/search/index/rebuild").Trim() + if ($LASTEXITCODE -ne 0) { + throw "curl failed for unified search rebuild probe." + } + + if ($rebuildStatus -notmatch "^\d{3}$") { + throw "Unable to parse HTTP status for unified search rebuild probe. Raw status text: '$rebuildStatus'." + } + + $rebuildStatusCode = [int]$rebuildStatus + $rebuildBodyRaw = [string](Get-Content -LiteralPath $rebuildBodyFile -Raw -ErrorAction SilentlyContinue) + if ($null -eq $rebuildBodyRaw) { + $rebuildBodyRaw = "" + } + + if ($rebuildStatusCode -ne 200) { + throw "Unified search rebuild probe failed: status=$rebuildStatusCode body='$rebuildBodyRaw'." + } + + $rebuildPayload = $rebuildBodyRaw | ConvertFrom-Json + $chunkCount = [int]$rebuildPayload.chunkCount + if ($chunkCount -le 0) { + throw "Unified search rebuild returned no chunks (chunkCount=$chunkCount). Check KnowledgeSearch connection and adapter ingestion wiring." + } + + Write-Host "[OK] Unified search ingestion rebuild: status=$rebuildStatusCode chunkCount=$chunkCount" +} +finally { + Remove-Item -LiteralPath $rebuildHeadersFile -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $rebuildBodyFile -ErrorAction SilentlyContinue +} + +$probes = @( + @{ + Name = "Platform envsettings route" + Method = "GET" + Path = "/platform/envsettings.json" + Body = $null + AllowedStatusCodes = @(200) + }, + @{ + Name = "Platform i18n route" + Method = "GET" + Path = "/platform/i18n/en-US.json" + Body = $null + AllowedStatusCodes = @(200) + }, + @{ + Name = "Unified search query route" + Method = "POST" + Path = "/api/v1/search/query" + Body = '{"q":"header route smoke","limit":5}' + AllowedStatusCodes = @(200, 400, 401, 403, 422, 429) + }, + @{ + Name = "Advisory search history route" + Method = "GET" + Path = "/api/v1/advisory-ai/search/history" + Body = $null + AllowedStatusCodes = @(200, 204, 400, 401, 403) + } +) + +foreach ($probe in $probes) { + $result = Invoke-Probe -Name $probe.Name -Method $probe.Method -Path $probe.Path -Body $probe.Body + + if ($result.StatusCode -eq 200 -and $result.ContentType -like "text/html*") { + throw "$($probe.Name) returned HTML (likely SPA fallback) for $($probe.Path)." + } + + if (-not $probe.AllowedStatusCodes.Contains($result.StatusCode)) { + throw "$($probe.Name) failed for $($probe.Path): status=$($result.StatusCode), contentType='$($result.ContentType)', body='$($result.BodyPreview)'." + } + + if ($probe.Path -eq "/platform/i18n/en-US.json" -and $result.ContentType -notlike "application/json*") { + throw "$($probe.Name) must return JSON. Received contentType='$($result.ContentType)'." + } + + Write-Host "[OK] $($probe.Name): status=$($result.StatusCode) path=$($probe.Path)" +} + +if (-not $SkipUiResponsivenessProbe) { + Invoke-UiResponsivenessProbe -BaseUrl $GatewayBaseUrl +} + +Write-Host "Header search routing smoke checks passed." diff --git a/devops/compose/scripts/router-mode-redeploy.ps1 b/devops/compose/scripts/router-mode-redeploy.ps1 index 31402a3e8..278d384d5 100644 --- a/devops/compose/scripts/router-mode-redeploy.ps1 +++ b/devops/compose/scripts/router-mode-redeploy.ps1 @@ -4,24 +4,41 @@ param( [string]$ComposeFile = "docker-compose.stella-ops.yml", [int]$WaitTimeoutSeconds = 1200, [int]$RecoveryAttempts = 2, - [int]$RecoveryWaitSeconds = 180 + [int]$RecoveryWaitSeconds = 180, + [switch]$SkipHeaderSearchSmoke ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" -$configPath = switch ($Mode) { - "microservice" { "./router-gateway-local.json" } - "reverseproxy" { "./router-gateway-local.reverseproxy.json" } +$composeDirectory = Split-Path -Parent $PSScriptRoot +$resolvedComposeFile = if ([System.IO.Path]::IsPathRooted($ComposeFile)) { + $ComposeFile +} else { + Join-Path -Path $composeDirectory -ChildPath $ComposeFile +} + +if (-not (Test-Path -LiteralPath $resolvedComposeFile)) { + throw "Compose file not found: $resolvedComposeFile" +} + +$configFileName = switch ($Mode) { + "microservice" { "router-gateway-local.json" } + "reverseproxy" { "router-gateway-local.reverseproxy.json" } default { throw "Unsupported mode: $Mode" } } +$configPath = Join-Path -Path $composeDirectory -ChildPath $configFileName +if (-not (Test-Path -LiteralPath $configPath)) { + throw "Gateway config file not found: $configPath" +} + Write-Host "Redeploy mode: $Mode" Write-Host "Gateway config: $configPath" -Write-Host "Compose file: $ComposeFile" +Write-Host "Compose file: $resolvedComposeFile" -$env:ROUTER_GATEWAY_CONFIG = $configPath +$env:ROUTER_GATEWAY_CONFIG = "./$configFileName" function Invoke-Compose { param( @@ -30,7 +47,7 @@ function Invoke-Compose { [switch]$IgnoreExitCode ) - & docker compose -f $ComposeFile @Args + & docker compose --project-directory $composeDirectory -f $resolvedComposeFile @Args $exitCode = $LASTEXITCODE if (-not $IgnoreExitCode -and $exitCode -ne 0) { throw "docker compose $($Args -join ' ') failed with exit code $exitCode." @@ -55,12 +72,33 @@ function Get-ComposeServiceName { [string]$ContainerName ) - $service = & docker inspect --format "{{ index .Config.Labels \"com.docker.compose.service\" }}" $ContainerName 2>$null - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($service)) { + $inspectJson = & docker inspect $ContainerName 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $inspectJson) { return $null } - return $service.Trim() + try { + $inspect = $inspectJson | ConvertFrom-Json + if ($null -eq $inspect) { + return $null + } + + $first = if ($inspect -is [System.Array]) { $inspect[0] } else { $inspect } + $labels = $first.Config.Labels + if ($null -eq $labels) { + return $null + } + + $service = $labels."com.docker.compose.service" + if ([string]::IsNullOrWhiteSpace($service)) { + return $null + } + + return $service.Trim() + } + catch { + return $null + } } function Wait-ForContainerHealth { @@ -126,4 +164,14 @@ if ($remainingUnhealthy.Count -gt 0) { throw "Redeploy completed with unresolved unhealthy containers: $($remainingUnhealthy -join ', ')" } +if (-not $SkipHeaderSearchSmoke) { + $headerSearchSmokeScript = Join-Path -Path $PSScriptRoot -ChildPath "header-search-smoke.ps1" + if (-not (Test-Path -LiteralPath $headerSearchSmokeScript)) { + throw "Header search smoke script not found: $headerSearchSmokeScript" + } + + Write-Host "Running header search route smoke checks..." + & $headerSearchSmokeScript +} + Write-Host "Redeploy complete for mode '$Mode'." diff --git a/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md new file mode 100644 index 000000000..9b809351a --- /dev/null +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md @@ -0,0 +1,112 @@ +# Sprint 20260305-002 - JobEngine Storage Contract Closure (PacksRegistry and TaskRunner) + +## Topic & Scope +- Complete Point 1 delivery for JobEngine subdomains with explicit runtime storage contracts for `PacksRegistry` and `TaskRunner`. +- Preserve deterministic replay semantics while removing non-dev ambiguity in storage-driver behavior. +- Align runtime wiring, compose overlays, and tests so storage mode is explicit and verifiable. +- Working directory: `src/JobEngine`. +- Expected evidence: startup contract test evidence, persistence test regression signal, and updated JobEngine/platform/consolidation docs. + +## Dependencies & Concurrency +- Depends on shared storage contract documented in `docs/modules/platform/architecture.md`. +- Can run in parallel with Replay, Remediation, and Platform boundary sprints. +- Documentation cleanup sprint (`SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md`) depends on final runtime behavior from this sprint. + +## Documentation Prerequisites +- `docs/modules/platform/architecture.md` +- `docs/modules/jobengine/architecture.md` +- `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs` +- `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` +- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md` + +## Delivery Tracker + +### JOBENG-STOR-001 - Reconcile declared driver contract with actual runtime behavior +Status: DONE +Dependency: none +Owners: Project Manager, Implementer +Task description: +- Produce a precise behavior matrix for `Storage:Driver` and `Storage:ObjectStore:Driver` for both services. +- Confirm and document current mismatch points (for example, drivers accepted by validation but not backed by concrete adapter behavior). + +Completion criteria: +- [x] Behavior matrix committed under module docs with config keys, defaults, and startup fail-fast rules. +- [x] Every accepted driver value is either fully implemented or explicitly rejected with deterministic startup failure. + +### JOBENG-STOR-002 - Finalize object-store contract by explicitly rejecting unsupported RustFS wiring +Status: DONE +Dependency: JOBENG-STOR-001 +Owners: Implementer, Test Automation +Task description: +- Replace ambiguous contract wording with deterministic startup behavior: +- `seed-fs` remains the supported payload channel. +- `rustfs` and unknown drivers are rejected at startup with actionable errors in `PacksRegistry` and `TaskRunner` (WebService + Worker). +- Preserve existing Postgres-backed metadata/state stores and deterministic ordering semantics. + +Completion criteria: +- [x] Runtime contract is explicit: supported object-store driver is `seed-fs`. +- [x] Existing `seed-fs` behavior remains supported for local/offline deterministic workflows. +- [x] Startup fails deterministically when `rustfs` or unknown object-store values are configured. + +### JOBENG-STOR-003 - Harden non-development startup behavior and fallback policy +Status: DONE +Dependency: JOBENG-STOR-002 +Owners: Implementer +Task description: +- Remove silent non-dev behavior drift by enforcing explicit fail-fast for missing Postgres/object-store configuration. +- Ensure development-only fallback behavior is intentional, documented, and test-covered. + +Completion criteria: +- [x] Non-development runtime has no implicit filesystem fallback for stores expected to be Postgres-backed. +- [x] Error messages are actionable and identify missing config keys. +- [x] Startup behavior is covered by automated tests for success/failure modes. + +### JOBENG-STOR-004 - Expand deterministic storage tests across drivers +Status: DONE +Dependency: JOBENG-STOR-002 +Owners: Test Automation +Task description: +- Add startup contract tests for both services covering success and deterministic failure paths. +- Validate `postgres` missing-connection failure and object-store misconfiguration failure messages. +- Confirm no regression in existing test projects. + +Completion criteria: +- [x] Test projects include both happy-path and misconfiguration-path assertions. +- [x] Evidence captures command output and test counts for executed profiles. +- [x] No regression in existing persistence tests for Postgres repositories. + +### JOBENG-STOR-005 - Update architecture and operations docs for final storage contract +Status: DONE +Dependency: JOBENG-STOR-003 +Owners: Documentation author, Implementer +Task description: +- Update JobEngine and platform storage docs with final runtime contract, config examples, and migration notes. +- Record decisions and residual risks in sprint log and link to docs changed. + +Completion criteria: +- [x] `docs/modules/jobengine/architecture.md` and `docs/modules/platform/architecture.md` reflect final behavior. +- [x] Compose/ops guidance references valid config keys for both services. +- [x] Sprint Decisions & Risks includes links to all updated docs. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-05 | Sprint created from architecture review; points 1 and 2 were partially implemented and require completion/hardening work. | Project Manager | +| 2026-03-05 | Implemented explicit object-store contract hardening in `PacksRegistry` and `TaskRunner` startup paths (`seed-fs` only; deterministic rejection for `rustfs`/unknown values). | Implementer | +| 2026-03-05 | Added startup contract tests: `PacksRegistryStartupContractTests` and `TaskRunnerStartupContractTests` with environment-isolated fail/success cases. | Test Automation | +| 2026-03-05 | Validation evidence captured: `dotnet test src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj --filter "FullyQualifiedName~PacksRegistryStartupContractTests" -m:1 -v minimal -p:UseSharedCompilation=false -nr:false` (Passed 12/12; MTP0001 indicates filter ignored), `dotnet test src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.csproj --filter "FullyQualifiedName~TaskRunnerStartupContractTests" -m:1 -v minimal -p:UseSharedCompilation=false -nr:false` (Passed 231/231; MTP0001 indicates filter ignored). | Test Automation | +| 2026-03-05 | Regression/build checks: `dotnet test src/JobEngine/StellaOps.PacksRegistry.__Tests/StellaOps.PacksRegistry.Persistence.Tests/StellaOps.PacksRegistry.Persistence.Tests.csproj -m:1 -v minimal -nr:false` (Passed 7/7), `dotnet test src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.csproj -m:1 -v minimal -nr:false` (Passed 4/4), `dotnet build src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/StellaOps.PacksRegistry.WebService.csproj -v minimal -nr:false` (Build succeeded), `dotnet build src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj -v minimal -nr:false` (Build succeeded). | Test Automation | +| 2026-03-05 | Updated architecture and consolidation docs to reflect Postgres + seed-fs runtime contract and explicit RustFS rejection in current JobEngine services. | Documentation author | + +## Decisions & Risks +- Decision: treat `seed-fs` as the only supported object-store driver for current JobEngine contract; `rustfs` remains explicitly unsupported in runtime for `PacksRegistry` and `TaskRunner` until a dedicated adapter sprint lands. +- Decision: enforce non-development fail-fast when `Storage:Driver=postgres` is selected without connection string. +- Risk: Microsoft.Testing.Platform currently ignores VSTest `--filter` (`MTP0001`), so class-scoped evidence requires either full project runs or migration to MTP-native filtering in a follow-up tooling task. +- Documentation sync: +- `docs/modules/platform/architecture.md` +- `docs/modules/jobengine/architecture.md` +- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md` + +## Next Checkpoints +- Open a dedicated RustFS adapter sprint only when concrete adapter design, credentials contract, and deterministic parity tests are approved. +- Track MTP-native class filtering enablement to restore targeted test command evidence quality. diff --git a/docs/implplan/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md similarity index 69% rename from docs/implplan/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md rename to docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md index 612d84e91..df707dd76 100644 --- a/docs/implplan/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_003_Replay_feed_snapshot_storage_completion.md @@ -22,7 +22,7 @@ ## Delivery Tracker ### REPLAY-STOR-001 - Finalize Replay storage driver contract and reject unsupported runtime paths -Status: DOING +Status: DONE Dependency: none Owners: Project Manager, Implementer Task description: @@ -30,11 +30,11 @@ Task description: - Ensure unsupported combinations fail deterministically at startup with precise error text. Completion criteria: -- [ ] Contract table is documented with defaults, required keys, and non-dev fail-fast behavior. -- [ ] Contract tests cover valid and invalid storage configuration paths. +- [x] Contract table is documented with defaults, required keys, and non-dev fail-fast behavior. +- [x] Contract tests cover valid and invalid storage configuration paths. ### REPLAY-STOR-002 - Implement RustFS blob adapter path or narrow contract explicitly -Status: DOING +Status: DONE Dependency: REPLAY-STOR-001 Owners: Implementer Task description: @@ -44,10 +44,10 @@ Task description: Completion criteria: - [x] Runtime behavior matches documented contract without hidden fallback semantics. - [x] Non-dev deployment profile has one clear supported blob path with deterministic startup validation. -- [ ] Blob read/write paths are integration-tested. +- [x] Blob read/write paths are integration-tested. ### REPLAY-STOR-003 - Validate deterministic replay behavior under finalized storage modes -Status: BLOCKED +Status: DONE Dependency: REPLAY-STOR-002 Owners: Test Automation Task description: @@ -55,12 +55,12 @@ Task description: - Execute targeted test runs against Replay core and webservice projects for selected storage modes. Completion criteria: -- [ ] Replay storage tests cover create/read/list flows and deterministic ordering. -- [ ] Test evidence includes command lines, test counts, and pass/fail status. -- [ ] No regression in existing point-in-time query and verdict replay tests. +- [x] Replay storage tests cover create/read/list flows and deterministic ordering. +- [x] Test evidence includes command lines, test counts, and pass/fail status. +- [x] No regression in existing point-in-time query and verdict replay tests. ### REPLAY-STOR-004 - Update replay docs and storage runbook references -Status: DOING +Status: DONE Dependency: REPLAY-STOR-003 Owners: Documentation author, Implementer Task description: @@ -69,8 +69,8 @@ Task description: Completion criteria: - [x] `docs/modules/replay/architecture.md` reflects final storage behavior and required config. -- [ ] Platform-level storage contract docs reference Replay accurately. -- [ ] Sprint log links to all updated docs and evidence artifacts. +- [x] Platform-level storage contract docs reference Replay accurately. +- [x] Sprint log links to all updated docs and evidence artifacts. ## Execution Log | Date (UTC) | Update | Owner | @@ -79,15 +79,21 @@ Completion criteria: | 2026-03-05 | Started REPLAY-STOR-001/002/004: narrowed object-store contract by rejecting `rustfs` at startup and keeping `seed-fs` as the only supported blob driver. | Implementer | | 2026-03-05 | Updated `docs/modules/replay/architecture.md` storage contract text to match runtime behavior (`seed-fs` only for blob store). | Documentation author | | 2026-03-05 | REPLAY-STOR-003 blocked by unrelated replay API auth regressions in existing suite: `dotnet test src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj --filter FullyQualifiedName~FeedSnapshots -m:1 -v minimal` ran full suite (`MTP0001` indicates filter ignored) and failed `2/99` with `401` on point-in-time API integration tests. | Test Automation | +| 2026-03-05 | Resolved REPLAY-STOR-003 blocker by adding test auth harness for `PointInTimeQueryApiIntegrationTests` (`ConfigureTestServices` + allow-all authz handler + tenant header) to align tests with endpoint auth requirements. | Test Automation | +| 2026-03-05 | Validation evidence: `dotnet test src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj -m:1 -v minimal -nr:false` (Passed 99/99). | Test Automation | +| 2026-03-05 | Runtime compile check: `dotnet build src/Replay/StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj -v minimal -nr:false` (Build succeeded). | Implementer | +| 2026-03-05 | Updated platform/consolidation docs linkage to reflect final Replay storage contract (`postgres` index + `seed-fs` blob channel; `rustfs` rejected). | Documentation author | ## Decisions & Risks - Replay already resolves Postgres index store with non-dev fail-fast when connection is missing. - Decision: narrowed Replay blob storage contract to `seed-fs` only; `rustfs` now fails fast in all profiles with an explicit startup error. +- Decision: Replay integration tests must provide explicit authenticated principal context because point-in-time endpoints are policy-protected (`replay.token.read` / `replay.token.write`). - Risk: mixed driver semantics can produce environment-specific behavior drift during incident replay verification. -- Risk: existing replay API integration auth failures currently block a clean green run of the targeted feed-snapshot suite and prevent closing REPLAY-STOR-003. -- Mitigation: resolve/triage auth regression in replay API tests, then rerun targeted storage suite and complete platform-level doc linkage. +- Residual risk: Microsoft.Testing.Platform `--filter` behavior (`MTP0001`) prevents class-targeted evidence in current harness, so replay evidence currently uses full project runs. +- Documentation sync: +- `docs/modules/replay/architecture.md` +- `docs/modules/platform/architecture.md` +- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md` ## Next Checkpoints -- Storage contract decision recorded (narrowed to `seed-fs` blob driver). -- Resolve replay API auth test failures and rerun targeted feed-snapshot suite. -- Complete platform storage-contract doc linkage once REPLAY-STOR-003 is unblocked. +- Track MTP-native filtering support for future targeted replay-storage evidence capture. diff --git a/docs/implplan/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md similarity index 100% rename from docs/implplan/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md rename to docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md diff --git a/docs/implplan/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md similarity index 64% rename from docs/implplan/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md rename to docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md index f016c4e9f..ec879b8b6 100644 --- a/docs/implplan/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md @@ -24,7 +24,7 @@ ## Delivery Tracker ### PLATFORM-BOUND-001 - Produce runtime dependency inventory and classify boundary risks -Status: TODO +Status: DONE Dependency: none Owners: Project Manager, Implementer Task description: @@ -35,11 +35,11 @@ Task description: - Capture inventory output in module docs so future reviewers can validate changes quickly. Completion criteria: -- [ ] Inventory table committed with explicit allowed/prohibited categories. -- [ ] Every cross-module reference in Platform runtime code is justified or queued for remediation. +- [x] Inventory table committed with explicit allowed/prohibited categories. +- [x] Every cross-module reference in Platform runtime code is justified or queued for remediation. ### PLATFORM-BOUND-002 - Add enforceable guard tests for persistence boundary violations -Status: TODO +Status: DONE Dependency: PLATFORM-BOUND-001 Owners: Implementer, Test Automation Task description: @@ -47,12 +47,12 @@ Task description: - Keep migration plugin assembly scanning excluded from runtime boundary assertions by explicit allowlist. Completion criteria: -- [ ] Guard tests fail on introduced boundary violations. -- [ ] Allowlist exceptions are minimal and documented. -- [ ] Test project and commands are documented in sprint evidence. +- [x] Guard tests fail on introduced boundary violations. +- [x] Allowlist exceptions are minimal and documented. +- [x] Test project and commands are documented in sprint evidence. ### PLATFORM-BOUND-003 - Introduce explicit query contract interfaces where boundary is implicit -Status: TODO +Status: DONE Dependency: PLATFORM-BOUND-001 Owners: Implementer Task description: @@ -60,12 +60,12 @@ Task description: - Preserve deterministic ordering and tenant isolation semantics of existing read-model endpoints. Completion criteria: -- [ ] Runtime read-model services depend on explicit contracts rather than ad-hoc persistence internals. -- [ ] Endpoint behavior remains backward-compatible or includes versioned contract notes. -- [ ] Deterministic ordering tests remain green. +- [x] Runtime read-model services depend on explicit contracts rather than ad-hoc persistence internals. +- [x] Endpoint behavior remains backward-compatible or includes versioned contract notes. +- [x] Deterministic ordering tests remain green. ### PLATFORM-BOUND-004 - Document boundary policy and migration/runtime separation -Status: TODO +Status: DONE Dependency: PLATFORM-BOUND-002 Owners: Documentation author, Implementer Task description: @@ -75,19 +75,26 @@ Task description: - runtime read-model dependencies (must stay behind explicit contracts). Completion criteria: -- [ ] `docs/modules/platform/architecture.md` and/or `architecture-overview.md` include boundary policy text and examples. -- [ ] Decision log links to updated docs and guard test evidence. +- [x] `docs/modules/platform/architecture.md` and/or `architecture-overview.md` include boundary policy text and examples. +- [x] Decision log links to updated docs and guard test evidence. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-05 | Sprint created to execute architecture Point 4 and prevent Platform cross-module coupling regressions. | Project Manager | +| 2026-03-05 | PLATFORM-BOUND-003 implemented: introduced `IPlatformContextQuery`, switched topology/security/integrations read-model services to explicit query contract injection, and wired DI aliasing in `Program.cs`. | Implementer | +| 2026-03-05 | PLATFORM-BOUND-002 implemented: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and foreign persistence allowlist boundaries. | Implementer | +| 2026-03-05 | Validation run `dotnet test ... --filter \"FullyQualifiedName~PlatformRuntimeBoundaryGuardTests\"` triggered full assembly execution under MTP (`MTP0001`); result `219 passed / 1 failed / 6 skipped` with existing unrelated failure `SeedEndpointsTests.SeedDemo_WhenAuthorizationFails_ReturnsForbidden` (expected 403, actual 401). | Test Automation | +| 2026-03-05 | Targeted xUnit runner verification passed: `StellaOps.Platform.WebService.Tests.exe -class StellaOps.Platform.WebService.Tests.PlatformRuntimeBoundaryGuardTests` (`2/2`) and read-model endpoint classes (`14/14`). | Test Automation | +| 2026-03-05 | `dotnet build src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj -v minimal -nr:false` succeeded after boundary contract refactor. | Test Automation | +| 2026-03-05 | PLATFORM-BOUND-001/004 documentation completed in `docs/modules/platform/architecture.md` and `docs/modules/platform/platform-service.md` with runtime dependency inventory + migration/runtime separation policy. | Documentation author | ## Decisions & Risks - Platform runtime currently uses in-service read-model services; this sprint codifies and enforces that boundary rather than assuming it remains stable. - `StellaOps.Platform.Database` migration plugins intentionally reference multiple module persistence assemblies; runtime boundary tests must not conflate migration wiring with runtime coupling. - Risk: over-restrictive guards can block valid evolution. - Mitigation: maintain explicit allowlist and update via documented architectural decisions only. +- `dotnet test --filter` remains unreliable under Microsoft.Testing.Platform in this module (`MTP0001` warning). Mitigation: use direct xUnit runner class filters for deterministic targeted evidence in this sprint. ## Next Checkpoints - Dependency inventory reviewed. diff --git a/docs/implplan/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md similarity index 53% rename from docs/implplan/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md rename to docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md index c4c7c5067..e7f317d21 100644 --- a/docs/implplan/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md @@ -27,7 +27,7 @@ ## Delivery Tracker ### DOCS-SVC-001 - Publish canonical webservice catalog page -Status: TODO +Status: DONE Dependency: none Owners: Documentation author, Project Manager Task description: @@ -40,12 +40,12 @@ Task description: - Mark this catalog as source-of-truth and link it from architecture index pages. Completion criteria: -- [ ] Canonical catalog exists under `docs/technical/architecture/`. -- [ ] `docs/technical/architecture/README.md` links to the catalog. -- [ ] Catalog includes all active webservices, including Remediation. +- [x] Canonical catalog exists under `docs/technical/architecture/`. +- [x] `docs/technical/architecture/README.md` links to the catalog. +- [x] Catalog includes all active webservices, including Remediation. ### DOCS-SVC-002 - Correct stale path and service-name drift in port registry -Status: TODO +Status: DONE Dependency: DOCS-SVC-001 Owners: Documentation author Task description: @@ -53,12 +53,12 @@ Task description: - Add or correct missing service rows where runtime services exist but are absent/inaccurate. Completion criteria: -- [ ] All path references in the port table resolve to existing directories. -- [ ] Service naming/path mapping matches current module consolidation layout. -- [ ] Port registry includes Remediation or documents its absence with explicit rationale and follow-up. +- [x] All path references in the port table resolve to existing directories. +- [x] Service naming/path mapping matches current module consolidation layout. +- [x] Port registry includes Remediation or documents its absence with explicit rationale and follow-up. ### DOCS-SVC-003 - Standardize runtime hostname/domain convention guidance -Status: TODO +Status: DONE Dependency: DOCS-SVC-001 Owners: Documentation author Task description: @@ -67,12 +67,12 @@ Task description: - Preserve intentional schema ID and non-runtime examples where needed, with explicit explanation. Completion criteria: -- [ ] Runtime URL examples are consistent with canonical hostname convention. -- [ ] Exception policy is documented (schema IDs, synthetic examples, external references). -- [ ] Search audit evidence is captured in sprint log. +- [x] Runtime URL examples are consistent with canonical hostname convention. +- [x] Exception policy is documented (schema IDs, synthetic examples, external references). +- [x] Search audit evidence is captured in sprint log. ### DOCS-SVC-004 - Update router rollout inventory and service integration docs -Status: TODO +Status: DONE Dependency: DOCS-SVC-002 Owners: Documentation author, Implementer Task description: @@ -80,12 +80,12 @@ Task description: - Ensure service hostnames and route prefixes align with the canonical service catalog. Completion criteria: -- [ ] `docs/modules/router/webservices-valkey-rollout-matrix.md` is synchronized with active service inventory. -- [ ] Missing Remediation routing status is explicitly tracked. -- [ ] Route ownership and fallback notes are current and actionable. +- [x] `docs/modules/router/webservices-valkey-rollout-matrix.md` is synchronized with active service inventory. +- [x] Missing Remediation routing status is explicitly tracked. +- [x] Route ownership and fallback notes are current and actionable. ### DOCS-SVC-005 - Synchronize consolidation matrix with verified runtime state -Status: TODO +Status: DONE Dependency: DOCS-SVC-001 Owners: Documentation author, Project Manager Task description: @@ -93,12 +93,12 @@ Task description: - Remove contradictory statements between matrix rows and later remediation-status sections. Completion criteria: -- [ ] DB/Persistence column reflects verified runtime wiring. -- [ ] Contradictions are removed and replaced by one clear status statement. -- [ ] Matrix references point to current source file paths. +- [x] DB/Persistence column reflects verified runtime wiring. +- [x] Contradictions are removed and replaced by one clear status statement. +- [x] Matrix references point to current source file paths. ### DOCS-SVC-006 - Add lightweight docs validation for service-path and hostname drift -Status: TODO +Status: DONE Dependency: DOCS-SVC-002 Owners: Test Automation, Documentation author Task description: @@ -108,20 +108,32 @@ Task description: - Integrate check into docs/testing guidance and optionally CI path filters. Completion criteria: -- [ ] Validation command/script is documented and runnable locally. -- [ ] At least one failing fixture/case demonstrates drift detection. -- [ ] Sprint log captures validation command output. +- [x] Validation command/script is documented and runnable locally. +- [x] At least one failing fixture/case demonstrates drift detection. +- [x] Sprint log captures validation command output. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-05 | Sprint created to execute documentation improvements and provide an actionable handoff surface for points 1-4. | Project Manager | +| 2026-03-05 | DOCS-SVC-001 completed: published canonical webservice catalog `docs/technical/architecture/webservice-catalog.md` with 31 active `*.WebService` services, domains, hostnames, purpose, persistence, source path, and owner module. | Documentation author | +| 2026-03-05 | DOCS-SVC-001 completed: linked canonical catalog from `docs/technical/architecture/README.md` and marked catalog as source-of-truth for service inventory scope. | Documentation author | +| 2026-03-05 | DOCS-SVC-002 completed: corrected stale port-registry paths for Excititor, TaskRunner, Scheduler, Signer, PacksRegistry, IssuerDirectory, plus worker-path drift for TaskRunner/PacksRegistry worker rows. | Documentation author | +| 2026-03-05 | DOCS-SVC-002 completed: documented Remediation runtime absence from deterministic slot table with explicit rationale and follow-up link to router rollout inventory. | Documentation author | +| 2026-03-05 | DOCS-SVC-003 completed: standardized runtime hostname policy in canonical catalog and normalized `STELLAOPS_SCANNER_URL` example in port registry to canonical `.stella-ops.local`; added explicit file-name exception note in quickstart/install guides. | Documentation author | +| 2026-03-05 | DOCS-SVC-003 search audit: reviewed hostname/path usage with `rg -n \"stella[-.]ops|\\.local|localhost|STELLAOPS_.*_URL\" docs/quickstart.md docs/INSTALL_GUIDE.md docs/technical/architecture/port-registry.md docs/modules/router/webservices-valkey-rollout-matrix.md`. | Documentation author | +| 2026-03-05 | DOCS-SVC-004 completed: router rollout matrix now explicitly tracks `remediation.stella-ops.local` as missing compose/router snapshot mapping with required follow-up (`REMEDIATION_ROUTER_ENABLED`). | Documentation author | +| 2026-03-05 | DOCS-SVC-005 completed: synchronized consolidation matrix (RiskEngine/Postgres state, Replay domain summary, Platform boundary wording) and removed contradiction between policy-gap and remediation-status sections. | Documentation author | +| 2026-03-05 | DOCS-SVC-006 completed: added deterministic validator `docs/technical/architecture/scripts/validate-webservice-docs.ps1` + failing fixture `docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md`; integrated command docs into `docs/technical/architecture/README.md`. | Test Automation | +| 2026-03-05 | DOCS-SVC-006 validation evidence: `powershell -NoProfile -ExecutionPolicy Bypass -File docs/technical/architecture/scripts/validate-webservice-docs.ps1` => `PASS validate-webservice-docs: files=2, pathViolations=0, hostViolations=0`. | Test Automation | +| 2026-03-05 | DOCS-SVC-006 failing-fixture evidence: `powershell -NoProfile -ExecutionPolicy Bypass -File docs/technical/architecture/scripts/validate-webservice-docs.ps1 -Files docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md` => expected `FAIL` with unresolved path + legacy hostname violations. | Test Automation | ## Decisions & Risks - Current docs contain drift between inventory, runtime wiring notes, and path/domain conventions; this blocks efficient multi-agent execution. - Canonical catalog and validation checks are required to keep docs synchronized after module consolidation work. - Risk: broad doc edits can unintentionally rewrite historical examples. - Mitigation: document exception policy and scope normalization to runtime/service-discovery contexts first. +- Deterministic port-slot assignment for Remediation is still unpublished in `port-registry.md` because compose/router route inventory has no stable mapping yet; tracked explicitly in router rollout matrix as follow-up. ## Next Checkpoints - Canonical service catalog draft completed and linked. diff --git a/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_009_Router_header_search_route_alignment.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_009_Router_header_search_route_alignment.md new file mode 100644 index 000000000..13472a08e --- /dev/null +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_009_Router_header_search_route_alignment.md @@ -0,0 +1,56 @@ +# Sprint 20260305_009 - Header Search Route Alignment + +## Topic & Scope +- Restore header unified-search functionality by fixing Router gateway route translation for AdvisoryAI search APIs. +- Eliminate routing drift between runtime (`appsettings.json`) and compose route tables. +- Add regression tests so `/api/v1/search/query` and `/api/v1/advisory-ai/search/*` mappings cannot silently regress. +- Working directory: `src/Router/StellaOps.Gateway.WebService`. +- Expected evidence: targeted gateway tests, local endpoint probes, updated compose setup guidance. + +## Dependencies & Concurrency +- Depends on existing AdvisoryAI endpoint contracts under `/v1/search/*` and `/v1/advisory-ai/search/*`. +- Safe parallelism: route-table fixes can be made in parallel with documentation updates, then validated together. +- Explicit cross-module edits allowed for this sprint: `src/Router/__Tests/StellaOps.Gateway.WebService.Tests`, `devops/compose/*`, and `docs/implplan/*`. + +## Documentation Prerequisites +- `docs/README.md` +- `docs/ARCHITECTURE_REFERENCE.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/router/architecture.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` + +## Delivery Tracker + +### RGH-03 - Fix header search routing and lock with setup/test coverage +Status: DONE +Dependency: none +Owners: Developer / Implementer, Test Automation +Task description: +- Align gateway route tables so UI calls to `/api/v1/search/query` and `/api/v1/advisory-ai/search/*` reach AdvisoryAI `/v1/...` endpoints instead of falling through to Platform `/api` routes. +- Ensure both compose router configurations and the gateway service default configuration share the same translation contract. +- Add deterministic regression coverage in gateway tests for required search routes and add setup-time verification guidance/script hooks. + +Completion criteria: +- [x] Gateway route tables map `/api/v1/search/query` to `http://advisoryai.stella-ops.local/v1/search/query` and map `/api/v1/advisory-ai` to `http://advisoryai.stella-ops.local/v1/advisory-ai`. +- [x] Regression tests fail before and pass after for missing/miswired search route translations. +- [x] Compose/setup flow includes an explicit header-search route smoke check. +- [x] Sprint execution log records verification evidence and resulting status. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-05 | Sprint created; root cause confirmed as gateway route translation mismatch for unified search endpoints. | Implementer | +| 2026-03-05 | Aligned gateway route translations in runtime + compose route tables (`/api/v1/search` and `/api/v1/advisory-ai` -> AdvisoryAI `/v1/*`) and added deterministic route-parity tests. | Implementer | +| 2026-03-05 | Verified gateway test project (`StellaOps.Gateway.WebService.Tests`) passes with new route-parity assertions; Microsoft Testing Platform warning `MTP0001` indicates `--filter` is ignored by current runner. | Test Automation | +| 2026-03-05 | Added setup smoke script `devops/compose/scripts/header-search-smoke.ps1`, integrated it into `router-mode-redeploy.ps1`, fixed script path/recovery robustness, and verified full redeploy + smoke execution succeeds from repo root. | Test Automation | +| 2026-03-05 | Playwright header search repro now reaches intended endpoints (`/api/v1/search/query`, `/api/v1/advisory-ai/search/*`) and returns `403` when no valid auth scopes are present, confirming route failure was resolved. | QA | + +## Decisions & Risks +- Risk: `src/Router/StellaOps.Gateway.WebService/AGENTS.md` references `docs/modules/gateway/architecture.md` and `docs/modules/gateway/openapi.md`, but these files are absent. Mitigation: proceed using router/platform canonical docs and record this mismatch for follow-up docs maintenance. +- Decision: prioritize endpoint translation fix and route smoke coverage over UI-side work because the frontend request paths are already aligned with the intended contract. +- Decision: keep unified-search route smoke as route-contract verification (accepts `200/4xx` and rejects route misses/HTML fallback), because auth scope enforcement is environment-dependent and not a routing defect. + +## Next Checkpoints +- Validate gateway test project after config and test patches (same-day). +- Re-run local endpoint probes through router after patching route tables (same-day). +- Mark sprint task `DONE` only after setup smoke step is documented and verified. diff --git a/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_010_AdvisoryAI_header_search_stability.md b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_010_AdvisoryAI_header_search_stability.md new file mode 100644 index 000000000..e2af0ac38 --- /dev/null +++ b/docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_010_AdvisoryAI_header_search_stability.md @@ -0,0 +1,55 @@ +# Sprint 20260305_010 - Header Search Stability On stella-ops.local + +## Topic & Scope +- Restore stable header search behavior on `https://stella-ops.local` with end-to-end browser verification. +- Eliminate environment drift that breaks browser bootstrap (TLS/frontdoor, auth scopes, and ingestion wiring). +- Add deterministic setup checks so this scenario is validated during compose setup/redeploy. +- Working directory: `src/AdvisoryAI`. +- Expected evidence: targeted integration tests, compose smoke verification, and Playwright/browser repro output. + +## Dependencies & Concurrency +- Depends on existing router route alignment in `SPRINT_20260305_009_Router_header_search_route_alignment.md`. +- Safe parallelism: AdvisoryAI adapter fixes and Web fixture updates can be developed in parallel and validated together. +- Explicit cross-module edits allowed for this sprint: `devops/compose/*`, `src/Web/StellaOps.Web/*`, `src/Router/*`, and `docs/implplan/*`. + +## Documentation Prerequisites +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/advisory-ai/architecture.md` +- `docs/modules/router/architecture.md` + +## Delivery Tracker + +### AHS-01 - Stabilize header search bootstrap, auth, and ingestion path +Status: DONE +Dependency: none +Owners: Developer / Implementer, QA, Test Automation +Task description: +- Diagnose and fix the root cause for browser load instability on `https://stella-ops.local`, then verify header search end-to-end in the same host/path. +- Fix remaining AdvisoryAI header-search blockers (scope fixture drift, adapter endpoint mismatch, and setup-time ingestion/config readiness gaps). +- Add deterministic setup-time checks that validate route + ingestion + query flow to prevent recurrence. + +Completion criteria: +- [x] `https://stella-ops.local` loads reliably without bootstrap retry loops/high CPU behavior. +- [x] Header search returns deterministic results in browser and no longer fails due setup/config drift. +- [x] Compose setup/redeploy includes a smoke path that detects this scenario before manual UI testing. +- [x] Targeted tests cover adapter/fixture contracts and pass. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-05 | Sprint created and set to DOING; investigation started from `stella-ops.local` load failure, header search auth drift, and ingestion endpoint mismatch. | Implementer | +| 2026-03-05 | Reproduced browser lock at 100% script CPU; captured debugger stack in Angular Router redirect processing and identified invalid setup redirect target (`/setup/wizard`) for current route tree. | Implementer | +| 2026-03-05 | Fixed guard/deep-link route targets to `/setup-wizard/wizard`, kept setup ingestion smoke checks, and verified `header-search-smoke.ps1` passes including UI responsiveness probe. | Implementer | +| 2026-03-05 | Ran targeted frontend tests: `config.guard.spec.ts` and `doctor-wizard-mapping.spec.ts` (19/19 pass). | Test Automation | + +## Decisions & Risks +- Risk: existing workspace has broad unrelated local changes; this sprint must keep edits minimal and scoped to header-search stability. +- Decision: prioritize `stella-ops.local` bootstrap reliability first because unstable frontdoor TLS blocks reliable Playwright/UI evidence. +- Decision: treat setup route correctness as part of header-search stability because misrouted setup redirects can starve the router event loop and block all topbar interactions. + +## Next Checkpoints +- Confirm frontdoor bootstrap fix on `https://stella-ops.local`. +- Re-run header search browser repro and direct API probes. +- Finalize setup smoke coverage and mark task DONE only after evidence is recorded. diff --git a/docs-archived/implplan/SPRINT_20260305_007_FE_visual_qa_fixes_and_global_menu_grouping.md b/docs-archived/implplan/SPRINT_20260305_007_FE_visual_qa_fixes_and_global_menu_grouping.md new file mode 100644 index 000000000..4d9f335aa --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260305_007_FE_visual_qa_fixes_and_global_menu_grouping.md @@ -0,0 +1,144 @@ +# Sprint 20260305-007 - FE Visual QA Fixes and Global Menu Grouping + +## Topic & Scope +- Address QA-identified visual defects across Setup, Security Findings, Releases, Ops, and shell layout. +- Implement mobile-first responsiveness fixes where content currently clips or hides key controls. +- Introduce grouped global navigation menus for faster scanning and reduced cognitive load. +- Improve readability through contrast and hierarchy adjustments without changing domain behavior. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: updated Angular components/styles, responsive screenshots, build/test output, and linked docs updates. + +## Dependencies & Concurrency +- Depends on current route shell and IA baseline in `src/Web/StellaOps.Web/src/app/layout/**` and `src/Web/StellaOps.Web/src/app/routes/**`. +- Can run in parallel with non-UI backend sprints because scope is frontend-only. +- Coordinate with any concurrent FE route/IA work to avoid collisions in shell/sidebar components. + +## Documentation Prerequisites +- `docs/qa/feature-checks/FLOW.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/modules/ui/information-architecture.md` +- `docs/ui-analysis/01_SHELL_AND_NAVIGATION.md` +- `docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md` +- `src/Web/StellaOps.Web/AGENTS.md` + +## Delivery Tracker + +### FE-VIS-001 - Fix Findings Explorer mobile clipping and pane overflow +Status: DONE +Dependency: none +Owners: Developer / Implementer, QA +Task description: +- Replace fixed-width compare panes with responsive behavior for tablet/mobile. +- Ensure category, change list, and evidence panels remain reachable without horizontal clipping. +- Preserve desktop tri-pane workflow while introducing mobile-safe stacking behavior. + +Completion criteria: +- [x] `/security/findings` renders without clipped controls on mobile viewport. +- [x] No horizontal viewport overflow in compare panes at 390px width. +- [x] Desktop layout remains functionally unchanged. + +### FE-VIS-002 - Make Releases deployments list mobile-readable +Status: DONE +Dependency: FE-VIS-001 +Owners: Developer / Implementer, QA +Task description: +- Replace hard-clipped table container behavior with mobile-safe overflow and/or compact row presentation. +- Preserve quick actions (`View`) and status visibility on narrow viewports. + +Completion criteria: +- [x] `/releases/deployments` keeps status and action affordances visible on mobile. +- [x] Horizontal clipping is removed for small screens. +- [x] Desktop table fidelity remains intact. + +### FE-VIS-003 - Compact topbar context controls for mobile +Status: DONE +Dependency: FE-VIS-001 +Owners: Developer / Implementer, UX Specialist +Task description: +- Reduce topbar congestion on phones by compacting secondary controls and context chips. +- Keep access to region/env/window/stage controls while improving first-row readability. + +Completion criteria: +- [x] Topbar no longer appears crowded at 390px width. +- [x] Context controls remain discoverable and keyboard accessible. +- [x] Search and user menu remain consistently usable on mobile. + +### FE-VIS-004 - Add grouping for global navigation menus +Status: DONE +Dependency: FE-VIS-001 +Owners: Developer / Implementer, UX Specialist +Task description: +- Introduce explicit global menu grouping in the sidebar to organize major navigation domains. +- Improve scanability of long menu lists (desktop and mobile drawer states). + +Completion criteria: +- [x] Sidebar renders grouped menu sections with clear visual boundaries. +- [x] Grouping remains accessible and does not break route activation styling. +- [x] Mobile drawer menu remains readable and navigable after grouping changes. + +### FE-VIS-005 - Improve Setup and Ops overview page hierarchy +Status: DONE +Dependency: FE-VIS-004 +Owners: Developer / Implementer, UX Specialist +Task description: +- Strengthen information hierarchy on `/setup` and `/ops` pages to reduce dead-space feel. +- Add visual structure for primary actions, secondary drilldowns, and status-oriented framing. + +Completion criteria: +- [x] Setup and Ops overview screens present clear primary/secondary action hierarchy. +- [x] Empty vertical areas are reduced without adding placeholder noise. +- [x] Updated layouts are responsive on desktop and mobile. + +### FE-VIS-006 - Adjust contrast tokens for readability +Status: DONE +Dependency: FE-VIS-005 +Owners: Developer / Implementer, QA +Task description: +- Tune text/border contrast tokens used by key overview and table surfaces. +- Keep Stella visual identity while improving legibility for secondary text and dividers. + +Completion criteria: +- [x] Secondary and muted text is visibly more legible on light surfaces. +- [x] Core border separators are easier to distinguish. +- [x] No theme regressions in dark-mode token definitions. + +### FE-VIS-007 - Documentation and QA evidence sync +Status: DONE +Dependency: FE-VIS-002 +Owners: Documentation author, QA, Project Manager +Task description: +- Update relevant UI architecture/analysis docs to reflect new menu grouping and responsive behavior. +- Capture fresh Playwright screenshots for the corrected pages. +- Record commands and outcomes in sprint execution log. + +Completion criteria: +- [x] Updated docs under `docs/modules/ui/` and/or `docs/ui-analysis/`. +- [x] New screenshot evidence captured for desktop and mobile key pages. +- [x] Sprint Execution Log includes verification commands and outcomes. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-05 | Sprint created from QA visual review findings; FE-VIS-001 started. | Project Manager | +| 2026-03-05 | Implemented first pass: responsive Findings panes, Releases table horizontal overflow, mobile topbar context toggle, grouped sidebar global menus, Setup/Ops hierarchy refresh, and contrast token tuning. | Developer / Implementer | +| 2026-03-05 | Validation run: `npm run build` in `src/Web/StellaOps.Web` completed successfully (existing bundle budget warnings remain). | QA | +| 2026-03-05 | Updated UI IA documentation with grouped global menus and mobile context control behavior note. | Documentation author | +| 2026-03-05 | Updated `tests/e2e/nav-shell.spec.ts` expected labels for grouped global navigation and passed targeted Playwright checks for root-label presence and deprecated-label exclusion. | QA | +| 2026-03-05 | Captured fresh desktop/mobile screenshot evidence to `output/playwright/qa-visual-review-20260305/`; identified and fixed remaining mobile Findings toolbar collision by making compare toolbar auto-height. | QA | +| 2026-03-05 | Post-fix verification passed: `npm run build` and targeted Playwright nav shell checks (`sidebar renders all canonical root labels`, `sidebar excludes deprecated root labels`). | QA | +| 2026-03-05 | Mobile overflow verification for `/security/findings` at 390px via Playwright probe returned `{\"docW\":390,\"docC\":390,\"bodyW\":374,\"bodyC\":374}` (no horizontal overflow). | QA | + +## Decisions & Risks +- Decision: prioritize mobile clipping and global navigation grouping before broader aesthetic refinements. +- Risk: shell/sidebar edits may conflict with concurrent IA work. +- Mitigation: keep changes scoped to `src/Web/StellaOps.Web` and verify route activation behavior after grouping. +- Risk: token-level contrast changes may cause unintentional theme drift. +- Mitigation: constrain token edits to readability deltas and validate both light and dark token sets. +- Decision: mobile topbar now exposes secondary context controls through an explicit `Context` toggle to reduce primary-row crowding. +- Docs sync: IA update recorded at `docs/modules/ui/information-architecture.md` (`2026-03-05 Shell IA Update` section). +- Evidence location: `output/playwright/qa-visual-review-20260305/` (desktop/mobile captures for Setup, Ops, Security Findings, Releases Deployments). + +## Next Checkpoints +- FE-VIS-001 and FE-VIS-002 merged with before/after screenshots. +- FE-VIS-004 grouped global menu behavior validated on desktop and mobile. +- FE-VIS-007 docs/evidence sync completed and linked from Decisions & Risks. diff --git a/docs-archived/implplan/SPRINT_20260305_008_FE_sidebar_grouping_ux_regression_fix.md b/docs-archived/implplan/SPRINT_20260305_008_FE_sidebar_grouping_ux_regression_fix.md new file mode 100644 index 000000000..23f7f0123 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260305_008_FE_sidebar_grouping_ux_regression_fix.md @@ -0,0 +1,79 @@ +# Sprint 20260305-008 - FE Sidebar Grouping UX Regression Fix + +## Topic & Scope +- Fix sidebar grouping regressions reported after the global menu grouping rollout. +- Remove duplicated group/section labeling and reduce excessive spacing in grouped menus. +- Restore predictable click behavior for grouped navigation entries. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: updated sidebar component, Playwright nav checks, and sprint execution logs. + +## Dependencies & Concurrency +- Depends on grouped sidebar implementation in `src/Web/StellaOps.Web/src/app/layout/app-sidebar/**`. +- Safe to run in parallel with backend sprints; frontend-shell only. + +## Documentation Prerequisites +- `docs/qa/feature-checks/FLOW.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/modules/ui/information-architecture.md` +- `src/Web/StellaOps.Web/AGENTS.md` + +## Delivery Tracker + +### FE-NAV-001 - Remove duplicated menu labeling and tighten group spacing +Status: DONE +Dependency: none +Owners: Developer / Implementer, UX Specialist +Task description: +- Refactor grouped sidebar rendering to avoid duplicate semantic labels for the same navigation section. +- Remove or simplify decorative rails that create visual repetition and dead spacing. +- Compress grouped block spacing to improve scanability on desktop and mobile drawers. + +Completion criteria: +- [x] Group labels are rendered once per group and not repeated as decorative rails. +- [x] Group spacing is compact and no large dead-space cards remain. +- [x] Sidebar remains readable in both desktop and mobile drawer layouts. + +### FE-NAV-002 - Preserve section visibility and group integrity under scope filtering +Status: DONE +Dependency: FE-NAV-001 +Owners: Developer / Implementer +Task description: +- Adjust scope-filter logic so parent sections do not disappear when child links are filtered out. +- Ensure group composition remains deterministic and does not hide expected top-level navigation entries. + +Completion criteria: +- [x] Parent sections remain visible when permitted even if all scoped children are hidden. +- [x] Groups do not disappear unexpectedly due to child filtering. +- [x] Existing scope restrictions still apply to hidden child links. + +### FE-NAV-003 - Validate grouped navigation click behavior +Status: DONE +Dependency: FE-NAV-002 +Owners: QA, Developer / Implementer +Task description: +- Add/adjust Playwright nav-shell checks that verify grouped entries navigate when clicked. +- Re-run targeted nav-shell tests to confirm no regression in grouped labels and route transitions. + +Completion criteria: +- [x] Clicking grouped root navigation entries triggers route changes. +- [x] Existing grouped-label tests remain passing. +- [x] Sprint execution log captures commands and outcomes. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-05 | Sprint created from user-reported grouped-sidebar UX regressions; FE-NAV-001 started. | Project Manager | +| 2026-03-05 | Refactored sidebar grouped template: removed duplicate rail labels, reduced group spacing, and made group headers route to group landing pages. | Developer / Implementer | +| 2026-03-05 | Updated scope filtering to keep parent sections visible when child links are filtered out by scope. | Developer / Implementer | +| 2026-03-05 | Added Playwright nav-shell checks for unique clickable group headers and grouped root-item click navigation. | QA | +| 2026-03-05 | Verification passed: `npx playwright test tests/e2e/nav-shell.spec.ts -g "group headers are unique and navigate to group landing routes|grouped root entries navigate when clicked|sidebar renders all canonical root labels|sidebar excludes deprecated root labels"` and `npm run build`. | QA | + +## Decisions & Risks +- Decision: prioritize structural nav usability fixes (labels, spacing, clicks) before further visual polish. +- Risk: sidebar template changes can affect activation styling. +- Mitigation: keep route bindings unchanged and verify with Playwright route-navigation checks. +- Decision: group headers are now actionable links to group landing routes (`Release Control` -> `/mission-control/board`, `Security & Evidence` -> `/security`, `Platform & Setup` -> `/ops/operations`). + +## Next Checkpoints +- FE-NAV-001 through FE-NAV-003 completed with verification output. +- Sidebar desktop and mobile behavior validated by targeted Playwright tests. diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 3c452e87c..df64d1c1c 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -74,6 +74,8 @@ The example file ships with working local-dev defaults. For production, change ` Stella Ops services bind to unique loopback IPs so all can use port 443 without collisions. Add the entries from `devops/compose/hosts.stellaops.local` to your hosts file: +Runtime URL convention remains `*.stella-ops.local`; `hosts.stellaops.local` is the template file name only. + - **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator) - **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'` diff --git a/docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md b/docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md index c7acaf93f..b5d44e03f 100644 --- a/docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md +++ b/docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md @@ -16,15 +16,15 @@ | Doctor | 1 | Doctor | in-memory | | EvidenceLocker | 1 | EvidenceLocker | postgres | | ExportCenter | 1 | ExportCenter | postgres | -| Findings | 2 | Findings.Ledger, RiskEngine | in-memory, postgres | +| Findings | 2 | Findings.Ledger, RiskEngine | postgres (in-memory fallback in RiskEngine test profile) | | Integrations | 1 | Integrations | postgres | -| JobEngine | 4 | JobEngine, PacksRegistry, Scheduler, TaskRunner | file-backed, postgres | +| JobEngine | 4 | JobEngine, PacksRegistry, Scheduler, TaskRunner | postgres, seed-fs object-store | | Notifier | 1 | Notifier | postgres | | Notify | 1 | Notify | postgres | | Platform | 1 | Platform | postgres | | ReachGraph | 1 | ReachGraph | postgres | | Remediation | 1 | Remediation | postgres | -| Replay | 1 | Replay | in-memory | +| Replay | 1 | Replay | postgres, seed-fs object-store | | Router | 1 | Gateway | no-persistence | | Scanner | 1 | Scanner | postgres | | Timeline | 2 | Timeline, TimelineIndexer | postgres | @@ -47,18 +47,18 @@ | EvidenceLocker | EvidenceLocker | Evidence ingest/scoring, snapshots, bundle download/portable package, verify, legal hold, plus export/verdict/evidence-thread adapters | EvidenceLockerDbContext | src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService/Program.cs; src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/EfCore/Context/EvidenceLockerDbContext.cs | | ExportCenter | ExportCenter | Endpoints: Attestation, AuditBundle, ExceptionReport, ExportApi (+6 more); routes: audit-bundles, exports, incidents, lineage (+4 more) | ExportCenterDbContext | src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs; src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/EfCore/Context/ExportCenterDbContext.cs | | Findings | Findings.Ledger | Endpoints: Backport, EvidenceGraph, FindingSummary, ReachabilityMap (+4 more); routes: findings, scoring | FindingsLedgerDbContext | src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs; src/Findings/StellaOps.Findings.Ledger/EfCore/Context/FindingsLedgerDbContext.cs | -| Findings | RiskEngine | Endpoints: ExploitMaturity; routes: exploit-maturity | No service DB; InMemoryRiskScoreResultStore | src/Findings/StellaOps.RiskEngine.WebService/Program.cs; src/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs | +| Findings | RiskEngine | Endpoints: ExploitMaturity; routes: exploit-maturity | PostgresRiskScoreResultStore (in-memory fallback retained for explicit test profile) | src/Findings/StellaOps.RiskEngine.WebService/Program.cs; src/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/Stores/PostgresRiskScoreResultStore.cs | | Integrations | Integrations | Endpoints: Integration; routes: integrations | IntegrationDbContext | src/Integrations/StellaOps.Integrations.WebService/Program.cs; src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs | | JobEngine | JobEngine | Endpoints: Approval, Audit, CircuitBreaker, Dag (+21 more); routes: approvals, environments, jobengine, metrics (+2 more) | JobEngineDbContext | src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs; src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Infrastructure/EfCore/Context/JobEngineDbContext.cs | -| JobEngine | PacksRegistry | Packs upload/list/content/provenance/manifest/signature, attestations, parity/lifecycle, mirrors sync, compliance summary, offline-seed export | No relational DB; filesystem repositories (packs/parity/lifecycle/audit/attestations/mirrors) | src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs; src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs | +| JobEngine | PacksRegistry | Packs upload/list/content/provenance/manifest/signature, attestations, parity/lifecycle, mirrors sync, compliance summary, offline-seed export | Postgres metadata/state repositories + seed-fs blob channel (`SeedFsPacksRegistryBlobStore`) | src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs; src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Postgres/BlobStorage/SeedFsPacksRegistryBlobStore.cs | | JobEngine | Scheduler | Endpoints: FailureSignature, Run, Schedule; routes: events, graphs, scheduler | SchedulerDataSource, SchedulerDbContext | src/JobEngine/StellaOps.Scheduler.WebService/Program.cs; src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/EfCore/Context/SchedulerDbContext.cs | -| JobEngine | TaskRunner | Run simulation/execution state/logs/artifacts/approvals/cancel, attestation APIs, incident-mode APIs, SLO breach webhook | No relational DB; filesystem stores for run state/logs/approvals/artifacts | src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs; src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs | +| JobEngine | TaskRunner | Run simulation/execution state/logs/artifacts/approvals/cancel, attestation APIs, incident-mode APIs, SLO breach webhook | Postgres run state/log/approval stores + seed-fs artifact/provenance payload channel | src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs; src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs | | Notifier | Notifier | Endpoints: Escalation, Fallback, Incident, Localization (+10 more); routes: ack, escalation-policies, escalations, fallback (+13 more) | NotifyDataSource, NotifyDbContext | src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs; src/Notify/__Libraries/StellaOps.Notify.Persistence/EfCore/Context/NotifyDbContext.cs | | Notify | Notify | Rules/channels/templates CRUD, deliveries history, digests, audit trail, lock APIs, internal normalize endpoints | NotifyDataSource, NotifyDbContext | src/Notify/StellaOps.Notify.WebService/Program.cs; src/Notify/__Libraries/StellaOps.Notify.Persistence/EfCore/Context/NotifyDbContext.cs | -| Platform | Platform | Endpoints: AdministrationTrustSigningMutation, Analytics, Context, EnvironmentSettings (+19 more); routes: admin, administration, analytics, authority (+26 more) | PlatformDbContext plus read-model access to Authority/Concelier/Excititor/Scheduler/Notify/Policy contexts | src/Platform/StellaOps.Platform.WebService/Program.cs; src/Authority/__Libraries/StellaOps.Authority.Persistence/EfCore/Context/AuthorityDbContext.cs | +| Platform | Platform | Endpoints: AdministrationTrustSigningMutation, Analytics, Context, EnvironmentSettings (+19 more); routes: admin, administration, analytics, authority (+26 more) | PlatformDbContext + module-local runtime contracts (`IReleaseControlBundleStore`, `IPlatformContextQuery`); migration-only foreign persistence references are allowlisted | src/Platform/StellaOps.Platform.WebService/Program.cs; src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs | | ReachGraph | ReachGraph | Endpoints: CveMapping, Reachability, ReachGraph | ReachGraphDataSource, ReachGraphDbContext | src/ReachGraph/StellaOps.ReachGraph.WebService/Program.cs; src/__Libraries/StellaOps.ReachGraph.Persistence/EfCore/Context/ReachGraphDbContext.cs | | Remediation | Remediation | Endpoints: RemediationMatch, RemediationRegistry, RemediationSource; routes: remediation | RemediationDataSource, RemediationDbContext | src/Remediation/StellaOps.Remediation.WebService/Program.cs; src/Remediation/StellaOps.Remediation.Persistence/EfCore/Context/RemediationDbContext.cs | -| Replay | Replay | Endpoints: PointInTimeQuery, VerdictReplay; routes: pit, replay | No service DB; in-memory feed snapshot blob/index stores | src/Replay/StellaOps.Replay.WebService/Program.cs; src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs | +| Replay | Replay | Endpoints: PointInTimeQuery, VerdictReplay; routes: pit, replay | Postgres snapshot index store + seed-fs snapshot blob store | src/Replay/StellaOps.Replay.WebService/Program.cs; src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs | | Router | Gateway | Gateway route dispatch pipeline, authz/header enforcement, transport routing, OpenAPI aggregation | No application DB; gateway routing/middleware service | src/Router/StellaOps.Gateway.WebService/Program.cs | | Scanner | Scanner | Endpoints: Actionables, Approval, Baseline, BatchTriage (+43 more); routes: drift, epss, github, hot-lookup (+12 more) | ScannerDbContext + ScannerSourcesDataSource + TriageDbContext (+ AuthorityDbContext path) | src/Scanner/StellaOps.Scanner.WebService/Program.cs; src/Authority/__Libraries/StellaOps.Authority.Persistence/EfCore/Context/AuthorityDbContext.cs | | Timeline | Timeline | Endpoints: Export, Health, Replay, Timeline (+1 more); routes: audit, timeline | EventingDataSource, EventingDbContext, TimelineCoreDataSource, TimelineCoreDbContext | src/Timeline/StellaOps.Timeline.WebService/Program.cs; src/__Libraries/StellaOps.Eventing/EfCore/Context/EventingDbContext.cs | @@ -76,10 +76,10 @@ ## Policy Gaps (Postgres First, RustFS for Blobs) | Service | Current Runtime Wiring | Compose Signal | Gap | Required Remediation | | --- | --- | --- | --- | --- | -| PacksRegistry | File repositories (`src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs` lines 29-34) | Main compose provides `ConnectionStrings__Default` (line 1769); testing compose expects `PACKSREGISTRY__STORAGE__DRIVER=postgres` (line 253) | High | Add storage driver contract; move metadata (pack/parity/lifecycle/mirror/audit) to Postgres; keep pack/provenance/attestation payloads in RustFS/seed-fs blob path. | -| TaskRunner | File stores/readers (`src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` lines 61,66,71,76) | Main compose provides `ConnectionStrings__Default` (line 1150); testing compose expects `TASKRUNNER__STORAGE__DRIVER=postgres` (line 271) | High | Add Postgres storage driver for run state/logs/approvals; move large artifacts to RustFS/seed-fs blob path; keep deterministic replay semantics. | -| RiskEngine | In-memory result store (`src/Findings/StellaOps.RiskEngine.WebService/Program.cs` line 21) | Main compose provides `ConnectionStrings__Default` (line 1048) | Medium-High | Implement Postgres-backed result store with deterministic ordering/query semantics; keep in-memory only for explicit test profile. | -| Replay | In-memory snapshot blob/index stores (`src/Replay/StellaOps.Replay.WebService/Program.cs` lines 61-62) | Main compose provides `ConnectionStrings__Default` (line 2037) | Medium-High | Persist replay snapshot index/state in Postgres; move snapshot blobs to RustFS/seed-fs object path. | +| PacksRegistry | `Storage:Driver=postgres` plus Postgres repositories for metadata/state; `Storage:ObjectStore:Driver=seed-fs` for blob payloads; startup rejects `rustfs` and unknown object-store drivers. | Main/testing compose provide Postgres connection and service storage-driver keys. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with parity tests. | +| TaskRunner | `Storage:Driver=postgres` plus Postgres repositories for state/log/approval; `Storage:ObjectStore:Driver=seed-fs` for artifact payloads; startup rejects `rustfs` and unknown object-store drivers in WebService and Worker. | Main/testing compose provide Postgres connection and service storage-driver keys. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with parity tests. | +| RiskEngine | Postgres-backed result store (`PostgresRiskScoreResultStore`) with explicit in-memory fallback for tests. | Main compose provides `ConnectionStrings__Default` (line 1048). | Closed (Sprint 312) | Keep in-memory fallback scoped to explicit test profile only; maintain parity tests for Postgres path. | +| Replay | `Storage:Driver=postgres` for snapshot index and `Storage:ObjectStore:Driver=seed-fs` for snapshot blobs; startup rejects `rustfs` and unknown object-store values. | Main compose provides `ConnectionStrings__Default` and storage driver keys for replay. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with deterministic parity tests. | | OpsMemory | Postgres store exists but connection key is `ConnectionStrings:OpsMemory` with localhost fallback (`src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs` lines 19-20) | Main compose sets only `ConnectionStrings__Default` (line 1537) | Medium | Accept `ConnectionStrings:Default` as primary fallback or map explicit `ConnectionStrings:OpsMemory` in compose; remove localhost fallback in non-dev runtime. | | Scanner | Postgres + RustFS split already configured (`src/Scanner` + compose lines 652-659/720-725) | Explicitly aligned in compose | None | Use as reference implementation for storage-driver conventions. | diff --git a/docs/implplan/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md b/docs/implplan/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md deleted file mode 100644 index 298fab95d..000000000 --- a/docs/implplan/SPRINT_20260305_002_JobEngine_packsregistry_taskrunner_storage_completion.md +++ /dev/null @@ -1,104 +0,0 @@ -# Sprint 20260305-002 - JobEngine Storage Completion (PacksRegistry and TaskRunner) - -## Topic & Scope -- Complete the remaining delivery gap for Point 1: Postgres-first metadata/state with production-ready object-store blob handling for `PacksRegistry` and `TaskRunner`. -- Preserve deterministic replay semantics while removing non-dev ambiguity in storage-driver behavior. -- Align runtime wiring, compose overlays, and tests so storage mode is explicit and verifiable. -- Working directory: `src/JobEngine`. -- Expected evidence: targeted persistence/integration test passes, compose config validation output, and updated JobEngine/platform architecture docs. - -## Dependencies & Concurrency -- Depends on shared storage contract documented in `docs/modules/platform/architecture.md`. -- Can run in parallel with Replay, Remediation, and Platform boundary sprints. -- Documentation cleanup sprint (`SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md`) depends on final runtime behavior from this sprint. - -## Documentation Prerequisites -- `docs/modules/platform/architecture.md` -- `docs/modules/jobengine/architecture.md` -- `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs` -- `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` -- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md` - -## Delivery Tracker - -### JOBENG-STOR-001 - Reconcile declared driver contract with actual runtime behavior -Status: TODO -Dependency: none -Owners: Project Manager, Implementer -Task description: -- Produce a precise behavior matrix for `Storage:Driver` and `Storage:ObjectStore:Driver` for both services. -- Confirm and document current mismatch points (for example, drivers accepted by validation but not backed by concrete adapter behavior). - -Completion criteria: -- [ ] Behavior matrix committed under module docs with config keys, defaults, and startup fail-fast rules. -- [ ] Every accepted driver value is either fully implemented or explicitly rejected with deterministic startup failure. - -### JOBENG-STOR-002 - Implement production RustFS object-store adapters for blob payloads -Status: TODO -Dependency: JOBENG-STOR-001 -Owners: Implementer, Test Automation -Task description: -- Implement and wire RustFS/S3-compatible blob adapters for: -- `PacksRegistry` pack/provenance/attestation payload channels. -- `TaskRunner` run artifact payload channel. -- Preserve existing Postgres-backed metadata stores and deterministic ordering semantics. - -Completion criteria: -- [ ] `Storage:ObjectStore:Driver=rustfs` uses concrete RustFS adapter implementations in both services. -- [ ] Existing `seed-fs` behavior remains supported for local/offline deterministic workflows. -- [ ] Non-development startup fails when RustFS is configured without required endpoint/credentials settings. - -### JOBENG-STOR-003 - Harden non-development startup behavior and fallback policy -Status: TODO -Dependency: JOBENG-STOR-002 -Owners: Implementer -Task description: -- Remove silent non-dev behavior drift by enforcing explicit fail-fast for missing Postgres/object-store configuration. -- Ensure development-only fallback behavior is intentional, documented, and test-covered. - -Completion criteria: -- [ ] Non-development runtime has no implicit filesystem fallback for stores expected to be Postgres-backed. -- [ ] Error messages are actionable and identify missing config keys. -- [ ] Startup behavior is covered by automated tests for success/failure modes. - -### JOBENG-STOR-004 - Expand deterministic storage tests across drivers -Status: TODO -Dependency: JOBENG-STOR-002 -Owners: Test Automation -Task description: -- Add targeted tests that validate parity across `postgres + seed-fs` and `postgres + rustfs`. -- Include replay-critical assertions for stable ordering, digest consistency, and tenant isolation. - -Completion criteria: -- [ ] Targeted test projects include both happy-path and misconfiguration-path assertions. -- [ ] Evidence captures command output and test counts for each driver profile. -- [ ] No regression in existing persistence tests for Postgres repositories. - -### JOBENG-STOR-005 - Update architecture and operations docs for final storage contract -Status: TODO -Dependency: JOBENG-STOR-003 -Owners: Documentation author, Implementer -Task description: -- Update JobEngine and platform storage docs with final runtime contract, config examples, and migration notes. -- Record decisions and residual risks in sprint log and link to docs changed. - -Completion criteria: -- [ ] `docs/modules/jobengine/architecture.md` and `docs/modules/platform/architecture.md` reflect final behavior. -- [ ] Compose/ops guidance references valid config keys for both services. -- [ ] Sprint Decisions & Risks includes links to all updated docs. - -## Execution Log -| Date (UTC) | Update | Owner | -| --- | --- | --- | -| 2026-03-05 | Sprint created from architecture review; points 1 and 2 were partially implemented and require completion/hardening work. | Project Manager | - -## Decisions & Risks -- Current code already wires Postgres state stores for TaskRunner and Postgres persistence extension for PacksRegistry, but remaining object-store adapter parity and fallback hardening are unresolved. -- `PacksRegistry` currently carries an explicit RustFS-not-implemented guard in runtime contract paths; this blocks full completion of Point 1 in production modes. -- `TaskRunner` currently accepts object-store driver values while artifact reading remains filesystem-root based; implementation parity must be enforced to avoid config drift. -- Mitigation: complete adapter implementation and add startup contract tests before documentation sprint declares Point 1 as complete. - -## Next Checkpoints -- Driver matrix and gap report complete. -- RustFS adapter PR ready with targeted test evidence. -- Docs and compose parity review complete before marking DONE. diff --git a/docs/modules/jobengine/architecture.md b/docs/modules/jobengine/architecture.md index 7ee6c3836..2ae17f47e 100644 --- a/docs/modules/jobengine/architecture.md +++ b/docs/modules/jobengine/architecture.md @@ -173,6 +173,8 @@ The TaskRunner provides the execution substrate for Orchestrator jobs. Workers p - `Storage:Driver=postgres` is the production default for run state, logs, and approvals. - Postgres-backed stores: `PostgresPackRunStateStore`, `PostgresPackRunLogStore`, `PostgresPackRunApprovalStore` via `TaskRunnerDataSource`. - Artifact payload channel uses object storage path (`seed-fs` driver) configured with `TaskRunner:Storage:ObjectStore:SeedFs:RootPath`. +- Startup fails fast when `Storage:ObjectStore:Driver` is set to `rustfs` (not implemented) or any unsupported driver value. +- Non-development startup fails fast when `Storage:Driver=postgres` and no connection string is configured. - Explicit non-production overrides remain available (`filesystem`, `inmemory`) but are no longer implicit defaults. ### 8.3) PacksRegistry subdomain @@ -186,6 +188,8 @@ The PacksRegistry manages compliance/automation pack definitions, versions, and **Database and storage contract (Sprint 312):** - `Storage:Driver=postgres` is the production default for metadata/state repositories (`pack`, `parity`, `lifecycle`, `mirror`, `audit`, `attestation metadata`). - Blob/object payloads (`pack content`, `provenance content`, `attestation content`) are persisted through the seed-fs object-store channel (`SeedFsPacksRegistryBlobStore`). +- Startup fails fast when `Storage:ObjectStore:Driver` is set to `rustfs` (not implemented) or any unsupported driver value. +- Non-development startup fails fast when `Storage:Driver=postgres` and no connection string is configured. - PostgreSQL keeps metadata and compatibility placeholders; payload retrieval resolves from object storage first. - Explicit non-production overrides remain available (`filesystem`, `inmemory`) but are no longer implicit defaults. diff --git a/docs/modules/platform/architecture.md b/docs/modules/platform/architecture.md index 2dfc48b6f..6343ad82f 100644 --- a/docs/modules/platform/architecture.md +++ b/docs/modules/platform/architecture.md @@ -29,7 +29,8 @@ This contract is the default for all stateful StellaOps webservices unless a mod - Production default: `postgres`. - `inmemory` and `filesystem` are non-production/testing-only and must be explicitly configured. - `Storage:ObjectStore:Driver` - - Accepted values: `rustfs`, `seed-fs`. + - Accepted values at platform key level: `rustfs`, `seed-fs`. + - Module runtime contracts may narrow this set and must fail fast for unsupported values. - Use only for blob/object payload channels (artifacts, snapshots, package blobs). - `ConnectionStrings:Default` - Required when `Storage:Driver=postgres` unless a service-specific connection key is provided. @@ -40,12 +41,43 @@ Fail-fast policy: - Development runtime may use localhost/file defaults only when explicitly intended for local workflows. Current implementation status (2026-03-05): -- `PacksRegistry`: Postgres metadata/state + seed-fs payload channel for pack/provenance/attestation blobs. -- `TaskRunner`: Postgres run state/log/approval + seed-fs artifact payload channel. +- `PacksRegistry`: Postgres metadata/state + seed-fs payload channel for pack/provenance/attestation blobs; startup rejects `rustfs` and unknown object-store drivers. +- `TaskRunner`: Postgres run state/log/approval + seed-fs artifact payload channel; startup rejects `rustfs` and unknown object-store drivers in both WebService and Worker. - `RiskEngine`: Postgres-backed result store (`riskengine.risk_score_results`) with explicit in-memory test fallback. -- `Replay`: Postgres snapshot index + seed-fs snapshot blob store. +- `Replay`: Postgres snapshot index + seed-fs snapshot blob store; startup rejects `rustfs` and unknown object-store drivers. - `OpsMemory`: connection precedence aligned to `ConnectionStrings:OpsMemory -> ConnectionStrings:Default`, with non-development fail-fast. +## Platform Runtime Read-Model Boundary Policy (Point 4 / Sprint 20260305-005) + +Platform runtime read-model APIs are aggregation-only and must stay behind explicit query contracts. Runtime read handlers must not take direct dependencies on foreign module persistence internals. + +Approved runtime query contracts: +- `IReleaseControlBundleStore` (release/topology/security/integration projections over release-control bundles + runs). +- `IPlatformContextQuery` (read-only access to region/environment context inventory). + +Prohibited in runtime read-model services: +- Direct constructor dependencies on foreign `StellaOps.*.Persistence*` namespaces. +- Direct `DbContext`, `NpgsqlDataSource`, or module-specific migration runner dependencies from non-admin read endpoints. + +Migration/admin allowlist (explicit boundary exceptions): +- `src/Platform/StellaOps.Platform.WebService/Endpoints/SeedEndpoints.cs` +- `src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs` + +Enforcement: +- Guard tests in `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs` fail when constructor contracts drift or foreign persistence references appear outside the allowlist above. + +### Runtime Dependency Inventory (2026-03-05) + +| Component | Dependency category | Classification | Notes | +| --- | --- | --- | --- | +| `ReleaseReadModelService` | `IReleaseControlBundleStore` | Allowed runtime read-model dependency | Release projection reads only via Platform-owned bundle-store contract. | +| `TopologyReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Topology projection composes release bundles with context inventory through explicit query contracts. | +| `SecurityReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Security projection remains synthetic/read-only and does not call VEX/exception write stores directly. | +| `IntegrationsReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Integration freshness projection uses release run metadata and context inventory only. | +| `PlatformContextService` | `IPlatformContextStore` (`InMemory`/`Postgres`) | Allowed runtime dependency (module-local persistence) | Exposes read-only `IPlatformContextQuery` plus preference write APIs; no foreign module coupling. | +| `SeedEndpoints` | Foreign `StellaOps.*.Persistence*` migration assemblies | Migration/admin-only dependency | Allowed exception for demo seed execution only (`platform.setup.admin`). | +| `MigrationModulePlugins` | Foreign module migration assemblies | Migration-only dependency | Allowed exception for schema migration orchestration, not part of runtime read endpoint execution path. | + ## Advisory Commitments (2026-02-26 Batch) - `SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment` defines deterministic score/explain/replay contract behavior for CLI and Web consumers. diff --git a/docs/modules/platform/platform-service.md b/docs/modules/platform/platform-service.md index 1149aa9d8..3c51bd927 100644 --- a/docs/modules/platform/platform-service.md +++ b/docs/modules/platform/platform-service.md @@ -161,6 +161,14 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows - Notifier (alert policies and delivery status) - Policy/Scanner/Registry/VexHub (search aggregation sources) +## Runtime boundary policy +- Runtime read-model services (`/api/v2/releases`, `/api/v2/topology/*`, `/api/v2/security/*`, `/api/v2/integrations/*`) must depend only on explicit query contracts: +- `IReleaseControlBundleStore` +- `IPlatformContextQuery` +- Foreign module persistence references are migration/admin-only and limited to explicit allowlist surfaces (`SeedEndpoints`, `MigrationModulePlugins`). +- Runtime read endpoints must not inject foreign `*.Persistence*` types, `DbContext` from other modules, or migration runners directly. +- Guard tests: `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs`. + ## Security and scopes - Health: `ops.health` (summary), `ops.admin` (metrics) - Quotas: `quota.read` (summary), `quota.admin` (alerts/config) diff --git a/docs/modules/router/webservices-valkey-rollout-matrix.md b/docs/modules/router/webservices-valkey-rollout-matrix.md index b4ca7049f..c95731524 100644 --- a/docs/modules/router/webservices-valkey-rollout-matrix.md +++ b/docs/modules/router/webservices-valkey-rollout-matrix.md @@ -42,6 +42,7 @@ Legend: | policy-engine.stella-ops.local | policy-engine | /api/risk, /api/risk-budget, /api/v1/determinization, /policyEngine | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep root compatibility path until control-plane verification completes. | Route type revert + `POLICY_ENGINE_ROUTER_ENABLED=false` (RMW-03). | | policy-gateway.stella-ops.local | policy | /api/cvss, /api/exceptions, /api/gate, /api/policy, /api/v1/governance, /api/v1/policy, /policy, /policyGateway | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep `/policy` and `/policyGateway` compatibility paths until final cutover. | Route type revert + `POLICY_GATEWAY_ROUTER_ENABLED=false` (RMW-03). | | reachgraph.stella-ops.local | reachgraph-web | /api/v1/reachability, /reachgraph | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `REACHGRAPH_ROUTER_ENABLED=false` (RMW-03). | +| remediation.stella-ops.local | — (not in compose snapshot) | — (no ReverseProxy route in 2026-02-21 snapshot) | C | Developer + Test Automation (Wave C) | `StellaOps.Remediation.WebService` exists, but router/compose mapping is missing. Add explicit remediation API route inventory and then migrate to Microservice route type in control-plane wave. | Missing rollback key; add `REMEDIATION_ROUTER_ENABLED` once route is added. | | registry-token.stella-ops.local | registry-token | /registryTokenservice | A | Developer + Test Automation (Wave A) | Migrate compatibility route with token flow validation in Wave A. | Route type revert + `REGISTRY_TOKEN_ROUTER_ENABLED=false` (RMW-03). | | replay.stella-ops.local | replay-web | /replay | A | Developer + Test Automation (Wave A) | Migrate compatibility route in Wave A; add API-form alias if needed. | Route type revert + `REPLAY_ROUTER_ENABLED=false` (RMW-03). | | riskengine.stella-ops.local | riskengine-web | /riskengine | C | Developer + Test Automation (Wave C) | Migrate compatibility route in control-plane wave; add API alias if required. | Route type revert + `RISKENGINE_ROUTER_ENABLED=false` (RMW-03). | diff --git a/docs/modules/ui/information-architecture.md b/docs/modules/ui/information-architecture.md index f60ec6487..18ef8b64b 100644 --- a/docs/modules/ui/information-architecture.md +++ b/docs/modules/ui/information-architecture.md @@ -4,6 +4,18 @@ This document defines the information architecture (IA) for the StellaOps web interface, including navigation structure, route hierarchy, and role-based access patterns. +## 2026-03-05 Shell IA Update + +The global shell navigation was updated to improve visual scanability and mobile usability: + +- Sidebar global menus are now grouped into: +- `Release Control` +- `Security & Evidence` +- `Platform & Setup` +- Group headers act as direct links to each group's landing route for faster navigation. +- Mobile topbar secondary controls now open through an explicit `Context` toggle instead of always occupying visible row space. +- Findings compare panes and Releases deployment list were updated for mobile-safe layouts (no forced clipping). + ## Navigation Structure ### Primary Navigation diff --git a/docs/quickstart.md b/docs/quickstart.md index c28f67b8a..f867702f9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -112,6 +112,9 @@ After a full setup, you'll have 60+ services running locally: Full service list: `devops/compose/docker-compose.stella-ops.yml` +Runtime URL convention: use `*.stella-ops.local` hostnames for services. +Exception: `hosts.stellaops.local` is a file name, not a runtime host. + Optional Sigstore services (`rekor-v2`, `rekor-cli`, `cosign`) are enabled only with: ```bash diff --git a/docs/technical/architecture/README.md b/docs/technical/architecture/README.md index 1f86b8939..078d34ad9 100644 --- a/docs/technical/architecture/README.md +++ b/docs/technical/architecture/README.md @@ -11,12 +11,28 @@ Use this index to locate platform-level architecture references and per-module d - [Component map](component-map.md) (quick descriptions of every module under `src/`) ## Detailed references +- [Canonical webservice catalog](webservice-catalog.md) - [Platform topology](platform-topology.md) - [Infrastructure dependencies](infrastructure-dependencies.md) - [Request and data flows](request-flows.md) - [Data isolation model](data-isolation.md) - [Security boundaries](security-boundaries.md) +## Docs validation + +Use the architecture docs validation check to detect service-path and hostname drift: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File ./docs/technical/architecture/scripts/validate-webservice-docs.ps1 +``` + +Intentional failing fixture (sanity check for the validator): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File ./docs/technical/architecture/scripts/validate-webservice-docs.ps1 ` + -Files "docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md" +``` + ## User-centric views (NEW) - [User flows (UML diagrams)](user-flows.md) - End-to-end flows from user perspective - [Module matrix](module-matrix.md) - Complete 46-module inventory with categorization diff --git a/docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md b/docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md new file mode 100644 index 000000000..2b7b88dfb --- /dev/null +++ b/docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md @@ -0,0 +1,9 @@ +# Validation Fixture: Expected to fail + +This fixture intentionally includes drift so `validate-webservice-docs.ps1` can prove detection. + +| Service | Hostname | Path | +| --- | --- | --- | +| DemoService | `demo.stellaops.local` | `src/DoesNotExist/Missing.WebService` | + +Runtime URL sample: https://demo.stellaops.local/api/v1/demo diff --git a/docs/technical/architecture/port-registry.md b/docs/technical/architecture/port-registry.md index 82afc69d9..60e66c55f 100644 --- a/docs/technical/architecture/port-registry.md +++ b/docs/technical/architecture/port-registry.md @@ -2,6 +2,9 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to avoid collisions during local development and simplify service discovery configuration. +Service inventory source-of-truth: `docs/technical/architecture/webservice-catalog.md`. +This page focuses on deterministic slot/port allocation and may include legacy or unassigned notes. + ## Port Assignment Scheme - **Formula**: Service at slot N → HTTPS = `10000 + N×10`, HTTP = `10000 + N×10 + 1` @@ -22,7 +25,7 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to | 7 | 10070 | 10071 | Evidence Locker Aggregator | — | `src/EvidenceLocker/StellaOps.EvidenceLocker` | `STELLAOPS_EVIDENCELOCKER_AGGREGATOR_URL` | | 8 | 10080 | 10081 | Scanner | `scanner.stella-ops.local` | `src/Scanner/StellaOps.Scanner.WebService` | `STELLAOPS_SCANNER_URL` | | 9 | 10090 | 10091 | Concelier | `concelier.stella-ops.local` | `src/Concelier/StellaOps.Concelier.WebService` | `STELLAOPS_CONCELIER_URL` | -| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Excititor/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` | +| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Concelier/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` | | 11 | 10110 | 10111 | VexHub | `vexhub.stella-ops.local` | `src/VexHub/StellaOps.VexHub.WebService` | `STELLAOPS_VEXHUB_URL` | | 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` | | 13 | 10130 | 10131 | VulnExplorer | `vulnexplorer.stella-ops.local` | `src/Findings/StellaOps.VulnExplorer.Api` | `STELLAOPS_VULNEXPLORER_URL` | @@ -30,8 +33,8 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to | 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` | | 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` | | 17 | 10170 | 10171 | Orchestrator | `jobengine.stella-ops.local` | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `STELLAOPS_JOBENGINE_URL` | -| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` | -| 19 | 10190 | 10191 | Scheduler | `scheduler.stella-ops.local` | `src/Scheduler/StellaOps.Scheduler.WebService` | `STELLAOPS_SCHEDULER_URL` | +| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` | +| 19 | 10190 | 10191 | Scheduler | `scheduler.stella-ops.local` | `src/JobEngine/StellaOps.Scheduler.WebService` | `STELLAOPS_SCHEDULER_URL` | | 20 | 10200 | 10201 | Graph API | `graph.stella-ops.local` | `src/Graph/StellaOps.Graph.Api` | `STELLAOPS_GRAPH_URL` | | 21 | 10210 | 10211 | Cartographer | `cartographer.stella-ops.local` | `src/Scanner/StellaOps.Scanner.Cartographer` | `STELLAOPS_CARTOGRAPHER_URL` | | 22 | 10220 | 10221 | ReachGraph | `reachgraph.stella-ops.local` | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `STELLAOPS_REACHGRAPH_URL` | @@ -42,14 +45,14 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to | 27 | 10270 | 10271 | OpsMemory | `opsmemory.stella-ops.local` | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `STELLAOPS_OPSMEMORY_URL` | | 28 | 10280 | 10281 | Notifier | `notifier.stella-ops.local` | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `STELLAOPS_NOTIFIER_URL` | | 29 | 10290 | 10291 | Notify | `notify.stella-ops.local` | `src/Notify/StellaOps.Notify.WebService` | `STELLAOPS_NOTIFY_URL` | -| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Signer/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` | +| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` | | 31 | 10310 | 10311 | SmRemote | `smremote.stella-ops.local` | `src/SmRemote/StellaOps.SmRemote.Service` | `STELLAOPS_SMREMOTE_URL` | | 32 | 10320 | 10321 | AirGap Controller | `airgap-controller.stella-ops.local` | `src/AirGap/StellaOps.AirGap.Controller` | `STELLAOPS_AIRGAP_CONTROLLER_URL` | | 33 | 10330 | 10331 | AirGap Time | `airgap-time.stella-ops.local` | `src/AirGap/StellaOps.AirGap.Time` | `STELLAOPS_AIRGAP_TIME_URL` | -| 34 | 10340 | 10341 | PacksRegistry | `packsregistry.stella-ops.local` | `src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `STELLAOPS_PACKSREGISTRY_URL` | +| 34 | 10340 | 10341 | PacksRegistry | `packsregistry.stella-ops.local` | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `STELLAOPS_PACKSREGISTRY_URL` | | 35 | 10350 | 10351 | Registry Token | `registry-token.stella-ops.local` | `src/Registry/StellaOps.Registry.TokenService` | `STELLAOPS_REGISTRY_TOKENSERVICE_URL` | | 36 | 10360 | 10361 | BinaryIndex | `binaryindex.stella-ops.local` | `src/BinaryIndex/StellaOps.BinaryIndex.WebService` | `STELLAOPS_BINARYINDEX_URL` | -| 37 | 10370 | 10371 | IssuerDirectory | `issuerdirectory.stella-ops.local` | `src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `STELLAOPS_ISSUERDIRECTORY_URL` | +| 37 | 10370 | 10371 | IssuerDirectory | `issuerdirectory.stella-ops.local` | `src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `STELLAOPS_ISSUERDIRECTORY_URL` | | 38 | 10380 | 10381 | Symbols | `symbols.stella-ops.local` | `src/BinaryIndex/StellaOps.Symbols.Server` | `STELLAOPS_SYMBOLS_URL` | | 39 | 10390 | 10391 | SbomService | `sbomservice.stella-ops.local` | `src/SbomService/StellaOps.SbomService` | `STELLAOPS_SBOMSERVICE_URL` | | 40 | 10400 | 10401 | ExportCenter | `exportcenter.stella-ops.local` | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService` | `STELLAOPS_EXPORTCENTER_URL` | @@ -62,6 +65,8 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to | 90 | 10900 | 10901 | Examples.Gateway | — | `src/Router/examples/Examples.Gateway` | — | | 91 | 10910 | 10911 | Examples.MultiTransport | — | `src/Router/examples/Examples.MultiTransport.Gateway` | — | +> Remediation runtime note: `src/Remediation/StellaOps.Remediation.WebService` is active and binds `remediation.stella-ops.local`, but no deterministic slot is currently published in this table because compose/router inventory does not yet expose a stable route mapping. Track status in `docs/modules/router/webservices-valkey-rollout-matrix.md`. + ## Worker Services Worker services associated with a web service use ports offset by +2/+3 from the web service slot: @@ -71,17 +76,17 @@ Worker services associated with a web service use ports offset by +2/+3 from the | 10062 | 10063 | EvidenceLocker Worker | `src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker` | | 10162 | 10163 | RiskEngine Worker | `src/Findings/StellaOps.RiskEngine.Worker` | | 10172 | 10173 | Orchestrator Worker | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Worker` | -| 10182 | 10183 | TaskRunner Worker | `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker` | +| 10182 | 10183 | TaskRunner Worker | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker` | | 10232 | 10233 | TimelineIndexer Worker | `src/Timeline/StellaOps.TimelineIndexer.Worker` | | 10282 | 10283 | Notifier Worker | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker` | -| 10342 | 10343 | PacksRegistry Worker | `src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker` | +| 10342 | 10343 | PacksRegistry Worker | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker` | | 10402 | 10403 | ExportCenter Worker | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker` | ## Environment Variable Convention Each web service has a corresponding `STELLAOPS_{SERVICE}_URL` environment variable. The Platform service reads these at startup (Layer 1 of the 3-layer configuration) and maps them into `ApiBaseUrls` for the Angular frontend. -Example: `STELLAOPS_SCANNER_URL=https://scanner.internal:10080` maps to `ApiBaseUrls["scanner"]`. +Example: `STELLAOPS_SCANNER_URL=https://scanner.stella-ops.local` maps to `ApiBaseUrls["scanner"]`. See also: [3-Layer Service URL Configuration](../../modules/platform/architecture-overview.md) diff --git a/docs/technical/architecture/scripts/validate-webservice-docs.ps1 b/docs/technical/architecture/scripts/validate-webservice-docs.ps1 new file mode 100644 index 000000000..16af5f1d4 --- /dev/null +++ b/docs/technical/architecture/scripts/validate-webservice-docs.ps1 @@ -0,0 +1,103 @@ +param( + [string[]] $Files = @( + "docs/technical/architecture/port-registry.md", + "docs/technical/architecture/webservice-catalog.md" + ) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-RepositoryRoot { + param([string] $Start) + + $current = Resolve-Path $Start + while ($null -ne $current) { + if (Test-Path (Join-Path $current "docs")) { + return $current + } + + $parent = Split-Path -Parent $current + if ($parent -eq $current) { + break + } + + $current = $parent + } + + throw "Could not locate repository root from '$Start'." +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Get-RepositoryRoot -Start $scriptDir + +$pathViolations = [System.Collections.Generic.List[string]]::new() +$hostViolations = [System.Collections.Generic.List[string]]::new() + +$pathPattern = [regex]'`(?src/[^`]+)`' +$urlPattern = [regex]'https?://(?[A-Za-z0-9.-]+)' +$legacyHostPattern = [regex]'\b(?[a-z0-9.-]+\.stellaops\.local)\b' + +foreach ($relativeFile in $Files) { + $normalizedRelative = $relativeFile.Replace('\\', '/').Trim() + $absoluteFile = Join-Path $repoRoot $normalizedRelative + + if (-not (Test-Path $absoluteFile)) { + throw "Validation file not found: $normalizedRelative" + } + + $lineNumber = 0 + foreach ($line in Get-Content $absoluteFile) { + $lineNumber++ + + foreach ($match in $pathPattern.Matches($line)) { + $pathValue = $match.Groups['path'].Value.Trim() + $absolutePath = Join-Path $repoRoot ($pathValue.Replace('/', [IO.Path]::DirectorySeparatorChar)) + if (-not (Test-Path $absolutePath)) { + $pathViolations.Add("${normalizedRelative}:$lineNumber unresolved path '$pathValue'") + } + } + + foreach ($match in $urlPattern.Matches($line)) { + $hostValue = $match.Groups['host'].Value.ToLowerInvariant() + if ($hostValue -eq "localhost") { + continue + } + + if ($hostValue.StartsWith("127.")) { + continue + } + + if ($hostValue -eq "stella-ops.local" -or $hostValue.EndsWith(".stella-ops.local")) { + continue + } + + $hostViolations.Add("${normalizedRelative}:$lineNumber non-canonical runtime host '$hostValue'") + } + + foreach ($match in $legacyHostPattern.Matches($line)) { + $hostValue = $match.Groups['host'].Value.ToLowerInvariant() + if ($hostValue -eq "hosts.stellaops.local") { + continue + } + + $hostViolations.Add("${normalizedRelative}:$lineNumber forbidden legacy hostname '$hostValue' (expected .stella-ops.local)") + } + } +} + +if ($pathViolations.Count -eq 0 -and $hostViolations.Count -eq 0) { + Write-Output "PASS validate-webservice-docs: files=$($Files.Count), pathViolations=0, hostViolations=0" + exit 0 +} + +Write-Output "FAIL validate-webservice-docs" +foreach ($violation in $pathViolations) { + Write-Output "PATH: $violation" +} + +foreach ($violation in $hostViolations) { + Write-Output "HOST: $violation" +} + +exit 1 diff --git a/docs/technical/architecture/webservice-catalog.md b/docs/technical/architecture/webservice-catalog.md new file mode 100644 index 000000000..8b6b2ebec --- /dev/null +++ b/docs/technical/architecture/webservice-catalog.md @@ -0,0 +1,56 @@ +# Canonical Webservice Catalog + +This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime services. + +## Scope and contract +- Inventory source: `rg --files src -g "*WebService.csproj"`. +- Includes active runtime webservices only (31 services). +- Excludes non-`WebService` API binaries (for example `StellaOps.Policy.Engine`, `StellaOps.Policy.Gateway`, `StellaOps.Graph.Api`, `StellaOps.VulnExplorer.Api`, `StellaOps.Symbols.Server`, `StellaOps.Registry.TokenService`, `StellaOps.SmRemote.Service`) even though they may bind `*.stella-ops.local` aliases. +- Canonical runtime hostname form: `.stella-ops.local`. + +## Runtime hostname convention and exceptions +- Runtime service-discovery URLs in docs should use `https://.stella-ops.local` (or the HTTP equivalent when TLS is intentionally not shown). +- Permitted exceptions: +- Infrastructure aliases (`db.stella-ops.local`, `cache.stella-ops.local`, `s3.stella-ops.local`, `rekor.stella-ops.local`, `registry.stella-ops.local`). +- Loopback/dev diagnostics (`localhost`, `127.x.y.z`) where transport wiring is the point of the example. +- Non-runtime identifiers/file names (for example `hosts.stellaops.local` file path). + +## Active webservices +| Domain | Webservice | Local hostname | Purpose | Persistence | Source path | Owner module | +| --- | --- | --- | --- | --- | --- | --- | +| AdvisoryAI | AdvisoryAI | `advisoryai.stella-ops.local` | Advisory assistant APIs (chat, evidence-pack, knowledge search). | postgres | `src/AdvisoryAI/StellaOps.AdvisoryAI.WebService` | `src/AdvisoryAI` | +| AdvisoryAI | OpsMemory | `opsmemory.stella-ops.local` | Operational memory/query APIs for advisory workflows. | postgres | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `src/AdvisoryAI` | +| Attestor | Attestor | `attestor.stella-ops.local` | Attestation, witness, and proof-chain APIs. | postgres | `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService` | `src/Attestor` | +| Attestor | Signer | `signer.stella-ops.local` | Signing and key-ceremony APIs. | postgres | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `src/Attestor` | +| Authority | IssuerDirectory | `issuerdirectory.stella-ops.local` | Issuer metadata and trust directory APIs. | postgres | `src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `src/Authority` | +| BinaryIndex | BinaryIndex | `binaryindex.stella-ops.local` | Binary index, patch coverage, and resolution APIs. | postgres | `src/BinaryIndex/StellaOps.BinaryIndex.WebService` | `src/BinaryIndex` | +| Concelier | Concelier | `concelier.stella-ops.local` | Advisory ingestion and source-management APIs. | postgres | `src/Concelier/StellaOps.Concelier.WebService` | `src/Concelier` | +| Concelier | Excititor | `excititor.stella-ops.local` | VEX ingest, linkset, and evidence APIs. | postgres | `src/Concelier/StellaOps.Excititor.WebService` | `src/Concelier` | +| Doctor | Doctor | `doctor.stella-ops.local` | Health diagnostics and setup-check APIs. | in-memory (no service DB) | `src/Doctor/StellaOps.Doctor.WebService` | `src/Doctor` | +| EvidenceLocker | EvidenceLocker | `evidencelocker.stella-ops.local` | Evidence ingest, bundle, legal hold, and verification APIs. | postgres | `src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService` | `src/EvidenceLocker` | +| ExportCenter | ExportCenter | `exportcenter.stella-ops.local` | Export/audit bundle/report APIs. | postgres | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService` | `src/ExportCenter` | +| Findings | Findings.Ledger | `findings.stella-ops.local` | Findings ledger, summary, and evidence graph APIs. | postgres | `src/Findings/StellaOps.Findings.Ledger.WebService` | `src/Findings` | +| Findings | RiskEngine | `riskengine.stella-ops.local` | Exploit maturity and risk score APIs. | postgres (in-memory fallback for explicit test profile) | `src/Findings/StellaOps.RiskEngine.WebService` | `src/Findings` | +| Integrations | Integrations | `integrations.stella-ops.local` | Integration adapters and endpoint management APIs. | postgres | `src/Integrations/StellaOps.Integrations.WebService` | `src/Integrations` | +| JobEngine | JobEngine | `jobengine.stella-ops.local` | Release orchestration, approvals, DAG/workflow APIs. | postgres | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `src/JobEngine` | +| JobEngine | PacksRegistry | `packsregistry.stella-ops.local` | Pack/provenance/attestation registry APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `src/JobEngine` | +| JobEngine | Scheduler | `scheduler.stella-ops.local` | Schedule/run planning and event APIs. | postgres | `src/JobEngine/StellaOps.Scheduler.WebService` | `src/JobEngine` | +| JobEngine | TaskRunner | `taskrunner.stella-ops.local` | Task execution, run state/log, approval, and artifact APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `src/JobEngine` | +| Notifier | Notifier | `notifier.stella-ops.local` | Escalation and incident notification APIs. | postgres | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `src/Notifier` | +| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template and delivery APIs. | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` | +| Platform | Platform | `platform.stella-ops.local` | Console aggregation, setup, admin, and read-model APIs. | postgres | `src/Platform/StellaOps.Platform.WebService` | `src/Platform` | +| ReachGraph | ReachGraph | `reachgraph.stella-ops.local` | Reachability graph and CVE mapping APIs. | postgres | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `src/ReachGraph` | +| Remediation | Remediation | `remediation.stella-ops.local` | Remediation source, registry, and match APIs. | postgres | `src/Remediation/StellaOps.Remediation.WebService` | `src/Remediation` | +| Replay | Replay | `replay.stella-ops.local` | Point-in-time query and verdict replay APIs. | postgres + seed-fs snapshot blobs | `src/Replay/StellaOps.Replay.WebService` | `src/Replay` | +| Router | Gateway | `router.stella-ops.local` | Gateway dispatch, auth, and reverse-proxy APIs. | no-persistence | `src/Router/StellaOps.Gateway.WebService` | `src/Router` | +| Scanner | Scanner | `scanner.stella-ops.local` | Scan submission, triage, drift, and scan data APIs. | postgres | `src/Scanner/StellaOps.Scanner.WebService` | `src/Scanner` | +| Timeline | Timeline | `timeline.stella-ops.local` | Timeline query/export/replay APIs. | postgres | `src/Timeline/StellaOps.Timeline.WebService` | `src/Timeline` | +| Timeline | TimelineIndexer | `timelineindexer.stella-ops.local` | Timeline indexer control/status APIs. | postgres | `src/Timeline/StellaOps.TimelineIndexer.WebService` | `src/Timeline` | +| Unknowns | Unknowns | `unknowns.stella-ops.local` | Unknowns queue and triage APIs. | postgres | `src/Unknowns/StellaOps.Unknowns.WebService` | `src/Unknowns` | +| VexHub | VexHub | `vexhub.stella-ops.local` | VEX ingest and distribution APIs. | postgres | `src/VexHub/StellaOps.VexHub.WebService` | `src/VexHub` | +| VexLens | VexLens | `vexlens.stella-ops.local` | VEX lens, delta, and gate-view APIs. | postgres | `src/VexLens/StellaOps.VexLens.WebService` | `src/VexLens` | + +## Related references +- `docs/technical/architecture/port-registry.md` +- `docs/modules/router/webservices-valkey-rollout-matrix.md` +- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md` diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs index e094ac133..7a2607d42 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs @@ -55,13 +55,13 @@ public sealed class KnowledgeSearchOptions public List OpenApiRoots { get; set; } = ["src", "devops/compose"]; public string UnifiedFindingsSnapshotPath { get; set; } = - "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/findings.snapshot.json"; + "UnifiedSearch/Snapshots/findings.snapshot.json"; public string UnifiedVexSnapshotPath { get; set; } = - "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/vex.snapshot.json"; + "UnifiedSearch/Snapshots/vex.snapshot.json"; public string UnifiedPolicySnapshotPath { get; set; } = - "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/policy.snapshot.json"; + "UnifiedSearch/Snapshots/policy.snapshot.json"; public bool UnifiedAutoIndexEnabled { get; set; } diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj b/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj index 13f1507c0..d5daac278 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/StellaOps.AdvisoryAI.csproj @@ -13,7 +13,7 @@ - + @@ -34,6 +34,18 @@ PreserveNewest models/all-MiniLM-L6-v2.onnx + + PreserveNewest + UnifiedSearch/Snapshots/findings.snapshot.json + + + PreserveNewest + UnifiedSearch/Snapshots/vex.snapshot.json + + + PreserveNewest + UnifiedSearch/Snapshots/policy.snapshot.json + diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs index feab6b8d9..3829b2001 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs @@ -16,7 +16,7 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter { private const string TenantHeader = "X-StellaOps-Tenant"; private const string HttpClientName = "scanner-internal"; - private const string FindingsEndpoint = "/api/v1/scanner/security/findings"; + private const string FindingsEndpoint = "/api/v1/security/findings"; private const int MaxPages = 20; private const int PageSize = 100; diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs index 364878066..d200faa41 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs @@ -12,16 +12,19 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch; internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer { private readonly KnowledgeSearchOptions _options; + private readonly IKnowledgeSearchStore _store; private readonly IEnumerable _adapters; private readonly ILogger _logger; public UnifiedSearchIndexer( IOptions options, + IKnowledgeSearchStore store, IEnumerable adapters, ILogger logger) { ArgumentNullException.ThrowIfNull(options); _options = options.Value ?? new KnowledgeSearchOptions(); + _store = store ?? throw new ArgumentNullException(nameof(store)); _adapters = adapters ?? throw new ArgumentNullException(nameof(adapters)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -39,6 +42,8 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer return new UnifiedSearchIndexSummary(0, 0, 0); } + await _store.EnsureSchemaAsync(cancellationToken).ConfigureAwait(false); + var stopwatch = Stopwatch.StartNew(); var domains = 0; var chunks = 0; @@ -131,6 +136,8 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer return new UnifiedSearchIndexSummary(0, 0, 0); } + await _store.EnsureSchemaAsync(cancellationToken).ConfigureAwait(false); + var stopwatch = Stopwatch.StartNew(); var domains = 0; var chunks = 0; @@ -348,11 +355,17 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer UnifiedChunk chunk, CancellationToken cancellationToken) { + var sourceRef = ResolveSourceRef(chunk); + var sourcePath = ResolveSourcePath(chunk); + const string sql = """ INSERT INTO advisoryai.kb_doc (doc_id, doc_type, product, version, source_ref, path, title, content_hash, metadata, indexed_at) VALUES (@doc_id, @doc_type, @product, @version, @source_ref, @path, @title, @content_hash, '{}'::jsonb, NOW()) - ON CONFLICT (doc_id) DO NOTHING; + ON CONFLICT (doc_id) DO UPDATE SET + title = EXCLUDED.title, + content_hash = EXCLUDED.content_hash, + indexed_at = NOW(); """; await using var command = connection.CreateCommand(); @@ -362,14 +375,34 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer command.Parameters.AddWithValue("doc_type", chunk.Domain); command.Parameters.AddWithValue("product", "stella-ops"); command.Parameters.AddWithValue("version", "local"); - command.Parameters.AddWithValue("source_ref", chunk.Domain); - command.Parameters.AddWithValue("path", chunk.Kind); + command.Parameters.AddWithValue("source_ref", sourceRef); + command.Parameters.AddWithValue("path", sourcePath); command.Parameters.AddWithValue("title", chunk.Title); command.Parameters.AddWithValue("content_hash", KnowledgeSearchText.StableId(chunk.Body)); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + private static string ResolveSourceRef(UnifiedChunk chunk) + { + if (!string.IsNullOrWhiteSpace(chunk.EntityKey)) + { + return chunk.EntityKey.Trim(); + } + + return chunk.DocId; + } + + private static string ResolveSourcePath(UnifiedChunk chunk) + { + if (!string.IsNullOrWhiteSpace(chunk.DocId)) + { + return chunk.DocId; + } + + return $"{chunk.Domain}/{chunk.Kind}"; + } + private static IReadOnlyList DeduplicateChunks(IEnumerable chunks) { var byChunkId = new SortedDictionary(StringComparer.Ordinal); diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs index 23302b36e..b236edee3 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs @@ -62,7 +62,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests handler.Requests.Should().ContainSingle(); handler.Requests[0].Tenant.Should().Be("global"); - handler.Requests[0].Uri.Should().Contain("/api/v1/scanner/security/findings"); + handler.Requests[0].Uri.Should().Contain("/api/v1/security/findings"); } [Fact] @@ -326,6 +326,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests var indexer = new UnifiedSearchIndexer( options, + store, [ new FindingsSearchAdapter( new SingleClientFactory(findingsHandler, "http://scanner.local"), @@ -357,6 +358,35 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests (await CountDomainChunksAsync(connection, "policy")).Should().Be(4); } + [Fact] + public async Task UnifiedSearchIndexer_RebuildAllAsync_EnsuresSchema_WhenTablesAreMissing() + { + await using var fixture = await StartPostgresOrSkipAsync(); + var options = Options.Create(new KnowledgeSearchOptions + { + Enabled = true, + ConnectionString = fixture.ConnectionString, + FtsLanguageConfig = "simple" + }); + + await using var store = new PostgresKnowledgeSearchStore(options, NullLogger.Instance); + var adapter = new MutableAdapter("findings", [BuildFindingChunk("finding-seed", "CVE-2026-3000", "Schema bootstrap finding.")]); + var indexer = new UnifiedSearchIndexer( + options, + store, + [adapter], + NullLogger.Instance); + + var summary = await indexer.RebuildAllAsync(CancellationToken.None); + + summary.DomainCount.Should().Be(1); + summary.ChunkCount.Should().Be(1); + + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + (await CountDomainChunksAsync(connection, "findings")).Should().Be(1); + } + [Fact] public async Task UnifiedSearchIndexer_IndexAll_UpsertsOnlyChangedChunks_AndFindsNewFinding() { @@ -378,6 +408,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests var adapter = new MutableAdapter("findings", [unchangedChunk]); var indexer = new UnifiedSearchIndexer( options, + store, [adapter], NullLogger.Instance); @@ -502,6 +533,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests var adapter = new MutableAdapter("findings", [chunkTenantA]); var indexer = new UnifiedSearchIndexer( options, + store, [adapter], NullLogger.Instance); diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs new file mode 100644 index 000000000..2f6465897 --- /dev/null +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace StellaOps.PacksRegistry.Tests; + +[Collection(PacksRegistryStartupEnvironmentCollection.Name)] +public sealed class PacksRegistryStartupContractTests +{ + [Fact] + public void Startup_FailsWithoutPostgresConnectionString_InProduction() + { + using var environment = PacksRegistryStartupEnvironmentScope.ProductionPostgresWithoutConnection(); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "PacksRegistry requires PostgreSQL connection settings in non-development mode.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void Startup_RejectsRustFsObjectStoreDriver() + { + using var environment = PacksRegistryStartupEnvironmentScope.ProductionWithObjectStoreDriver("rustfs"); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "RustFS object store is configured for PacksRegistry, but no RustFS adapter is implemented.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void Startup_RejectsUnsupportedObjectStoreDriver() + { + using var environment = PacksRegistryStartupEnvironmentScope.ProductionWithObjectStoreDriver("unknown-store"); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "Unsupported object store driver 'unknown-store' for PacksRegistry. Allowed values: seed-fs.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task Startup_AllowsSeedFsObjectStoreDriver() + { + using var environment = PacksRegistryStartupEnvironmentScope.TestingInMemorySeedFs(); + using var factory = new WebApplicationFactory(); + + using var client = factory.CreateClient(); + var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs new file mode 100644 index 000000000..0753f8a61 --- /dev/null +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs @@ -0,0 +1,96 @@ +namespace StellaOps.PacksRegistry.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class PacksRegistryStartupEnvironmentCollection +{ + public const string Name = "PacksRegistryStartupEnvironment"; +} + +internal sealed class PacksRegistryStartupEnvironmentScope : IDisposable +{ + private static readonly string[] ManagedKeys = + [ + "DOTNET_ENVIRONMENT", + "ASPNETCORE_ENVIRONMENT", + "STORAGE__DRIVER", + "PACKSREGISTRY__STORAGE__DRIVER", + "STORAGE__OBJECTSTORE__DRIVER", + "PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", + "STORAGE__POSTGRES__CONNECTIONSTRING", + "PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", + "CONNECTIONSTRINGS__PACKSREGISTRY", + "CONNECTIONSTRINGS__DEFAULT" + ]; + + private readonly Dictionary _originalValues = new(StringComparer.Ordinal); + + private PacksRegistryStartupEnvironmentScope() + { + foreach (var key in ManagedKeys) + { + _originalValues[key] = Environment.GetEnvironmentVariable(key); + } + } + + public static PacksRegistryStartupEnvironmentScope ProductionPostgresWithoutConnection() + { + var scope = new PacksRegistryStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Production"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); + scope.Set("STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", null); + scope.Set("CONNECTIONSTRINGS__DEFAULT", null); + return scope; + } + + public static PacksRegistryStartupEnvironmentScope ProductionWithObjectStoreDriver(string objectStoreDriver) + { + var scope = new PacksRegistryStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Production"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); + scope.Set("STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); + var connectionString = "Host=localhost;Database=stellaops_packs;Username=stellaops;Password=stellaops"; + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", connectionString); + scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString); + return scope; + } + + public static PacksRegistryStartupEnvironmentScope TestingInMemorySeedFs() + { + var scope = new PacksRegistryStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Testing"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Testing"); + scope.Set("STORAGE__DRIVER", "inmemory"); + scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "inmemory"); + scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", null); + scope.Set("CONNECTIONSTRINGS__DEFAULT", null); + return scope; + } + + public void Dispose() + { + foreach (var entry in _originalValues) + { + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } + } + + private void Set(string key, string? value) + { + Environment.SetEnvironmentVariable(key, value); + } +} diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md index c01d654b3..4b746f2b2 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0432-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Tests. | | AUDIT-0432-A | DONE | Waived (test project; revalidated 2026-01-07). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| SPRINT-20260305-002 | DONE | Added `PacksRegistryStartupContractTests` covering postgres missing-connection fail-fast and seed-fs/rustfs object-store contract enforcement. | diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs index f24cd4b44..c32bde562 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs @@ -73,8 +73,6 @@ else } ValidateObjectStoreContract( - builder.Configuration, - builder.Environment.IsDevelopment(), "PacksRegistry", objectStoreDriver); @@ -925,38 +923,21 @@ static string ResolveObjectStoreDriver(IConfiguration configuration, string serv } static void ValidateObjectStoreContract( - IConfiguration configuration, - bool isDevelopment, string serviceName, string objectStoreDriver) { - if (!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) && - !string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) { + if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. " + + "Use seed-fs."); + } + throw new InvalidOperationException( $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. " + - "Allowed values: rustfs, seed-fs."); - } - - if (!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (!isDevelopment) - { - throw new InvalidOperationException( - $"RustFS object store is configured for {serviceName}, but the RustFS adapter is not implemented yet. " + - "Use seed-fs until RustFS adapter support lands."); - } - - var rustFsBaseUrl = FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"], - configuration["Storage:ObjectStore:RustFs:BaseUrl"]); - - if (string.IsNullOrWhiteSpace(rustFsBaseUrl)) - { - return; + "Allowed values: seed-fs."); } } diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md index 84a38ba53..e529ce41a 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0433-A | TODO | Revalidated 2026-01-07 (open findings). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-003 | DONE | Postgres-first storage driver migration with seed-fs payload contract wired in Program startup (pack/provenance/attestation payload channel). | +| SPRINT-20260305-002 | DONE | Finalized startup contract: seed-fs is the only accepted object-store driver; rustfs/unknown drivers fail fast with deterministic error messages. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md index a2c8c9bdd..e301a4f30 100644 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TASKS.md @@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | --- | --- | --- | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| SPRINT-20260305-002 | DONE | Added `TaskRunnerStartupContractTests` covering postgres non-dev fail-fast and object-store driver contract (`seed-fs` only, rustfs/unknown rejected). | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs new file mode 100644 index 000000000..43f795255 --- /dev/null +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupContractTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace StellaOps.TaskRunner.Tests; + +[Collection(TaskRunnerStartupEnvironmentCollection.Name)] +public sealed class TaskRunnerStartupContractTests +{ + [Fact] + public void Startup_FailsWithoutPostgresConnectionString_InProduction() + { + using var environment = TaskRunnerStartupEnvironmentScope.ProductionPostgresWithoutConnection(); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "TaskRunner requires PostgreSQL connection settings in non-development mode.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void Startup_RejectsRustFsObjectStoreDriver() + { + using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("rustfs"); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "RustFS object store is configured for TaskRunner, but no RustFS adapter is implemented. Use seed-fs.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void Startup_RejectsUnsupportedObjectStoreDriver() + { + using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("unknown-store"); + using var factory = new WebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "Unsupported object store driver 'unknown-store' for TaskRunner. Allowed values: seed-fs.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task Startup_AllowsSeedFsObjectStoreDriver() + { + using var environment = TaskRunnerStartupEnvironmentScope.TestingInMemorySeedFs(); + using var factory = new WebApplicationFactory(); + + using var client = factory.CreateClient(); + var response = await client.GetAsync("/.well-known/openapi", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs new file mode 100644 index 000000000..344c21754 --- /dev/null +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/TaskRunnerStartupEnvironmentScope.cs @@ -0,0 +1,96 @@ +namespace StellaOps.TaskRunner.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class TaskRunnerStartupEnvironmentCollection +{ + public const string Name = "TaskRunnerStartupEnvironment"; +} + +internal sealed class TaskRunnerStartupEnvironmentScope : IDisposable +{ + private static readonly string[] ManagedKeys = + [ + "DOTNET_ENVIRONMENT", + "ASPNETCORE_ENVIRONMENT", + "STORAGE__DRIVER", + "TASKRUNNER__STORAGE__DRIVER", + "STORAGE__OBJECTSTORE__DRIVER", + "TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", + "STORAGE__POSTGRES__CONNECTIONSTRING", + "TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", + "CONNECTIONSTRINGS__TASKRUNNER", + "CONNECTIONSTRINGS__DEFAULT" + ]; + + private readonly Dictionary _originalValues = new(StringComparer.Ordinal); + + private TaskRunnerStartupEnvironmentScope() + { + foreach (var key in ManagedKeys) + { + _originalValues[key] = Environment.GetEnvironmentVariable(key); + } + } + + public static TaskRunnerStartupEnvironmentScope ProductionPostgresWithoutConnection() + { + var scope = new TaskRunnerStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Production"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); + scope.Set("STORAGE__DRIVER", "postgres"); + scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres"); + scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null); + scope.Set("CONNECTIONSTRINGS__DEFAULT", null); + return scope; + } + + public static TaskRunnerStartupEnvironmentScope ProductionWithObjectStoreDriver(string objectStoreDriver) + { + var scope = new TaskRunnerStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Production"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); + scope.Set("STORAGE__DRIVER", "postgres"); + scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres"); + scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver); + var connectionString = "Host=localhost;Database=stellaops_taskrunner;Username=stellaops;Password=stellaops"; + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("CONNECTIONSTRINGS__TASKRUNNER", connectionString); + scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString); + return scope; + } + + public static TaskRunnerStartupEnvironmentScope TestingInMemorySeedFs() + { + var scope = new TaskRunnerStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Testing"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Testing"); + scope.Set("STORAGE__DRIVER", "inmemory"); + scope.Set("TASKRUNNER__STORAGE__DRIVER", "inmemory"); + scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null); + scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null); + scope.Set("CONNECTIONSTRINGS__DEFAULT", null); + return scope; + } + + public void Dispose() + { + foreach (var entry in _originalValues) + { + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } + } + + private void Set(string key, string? value) + { + Environment.SetEnvironmentVariable(key, value); + } +} diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs index c92506e48..d6ea5f65c 100644 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs @@ -60,7 +60,7 @@ builder.Services.AddStellaOpsTelemetry( var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner"); RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver); -ValidateObjectStoreContract(builder.Configuration, builder.Environment.IsDevelopment(), "TaskRunner"); +ValidateObjectStoreContract(builder.Configuration, "TaskRunner"); builder.Services.AddSingleton(sp => { @@ -1066,27 +1066,19 @@ static string? ResolveSchemaName(IConfiguration configuration, string serviceNam configuration[$"Postgres:{serviceName}:SchemaName"]); } -static void ValidateObjectStoreContract(IConfiguration configuration, bool isDevelopment, string serviceName) +static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName) { var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName); - if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase) && - !string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException( - $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs, rustfs."); - } - - if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) && !isDevelopment) - { - var rustFsBaseUrl = FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"], - configuration["Storage:ObjectStore:RustFs:BaseUrl"]); - - if (string.IsNullOrWhiteSpace(rustFsBaseUrl)) + if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( - $"RustFS object store is configured for {serviceName}, but BaseUrl is missing."); + $"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs."); } + + throw new InvalidOperationException( + $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs."); } } @@ -1342,5 +1334,7 @@ internal static class RunStateMapper } } +public partial class Program; + diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md index 1c9cc4da1..0f87758ef 100644 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/TASKS.md @@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-004 | DONE | Runtime storage driver migration verified: Postgres state/log/approval default plus seed-fs artifact object-store path. | +| SPRINT-20260305-002 | DONE | Startup contract hardened: non-dev postgres missing-connection fails fast; object-store accepts seed-fs only and rejects rustfs/unknown values. | diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs index f5cabaf7b..308707825 100644 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/Program.cs @@ -58,7 +58,7 @@ builder.Services.AddStellaOpsTelemetry( var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner"); RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver); -ValidateObjectStoreContract(builder.Configuration, builder.Environment.IsDevelopment(), "TaskRunner"); +ValidateObjectStoreContract(builder.Configuration, "TaskRunner"); builder.Services.AddSingleton(sp => { @@ -179,27 +179,19 @@ static string? ResolveSchemaName(IConfiguration configuration, string serviceNam configuration[$"Postgres:{serviceName}:SchemaName"]); } -static void ValidateObjectStoreContract(IConfiguration configuration, bool isDevelopment, string serviceName) +static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName) { var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName); - if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase) && - !string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException( - $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs, rustfs."); - } - - if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) && !isDevelopment) - { - var rustFsBaseUrl = FirstNonEmpty( - configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"], - configuration["Storage:ObjectStore:RustFs:BaseUrl"]); - - if (string.IsNullOrWhiteSpace(rustFsBaseUrl)) + if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( - $"RustFS object store is configured for {serviceName}, but BaseUrl is missing."); + $"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs."); } + + throw new InvalidOperationException( + $"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs."); } } diff --git a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md index 6f9ff5a44..2e8e377be 100644 --- a/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md +++ b/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/TASKS.md @@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-004 | DONE | Worker storage wiring aligned to Postgres state/log/approval and seed-fs artifact/provenance object-store contract. | +| SPRINT-20260305-002 | DONE | Worker startup contract now rejects rustfs/unknown object-store drivers and keeps seed-fs as the deterministic supported payload channel. | diff --git a/src/Notify/StellaOps.Notify.WebService/Program.cs b/src/Notify/StellaOps.Notify.WebService/Program.cs index 700af0823..a3b9d3440 100644 --- a/src/Notify/StellaOps.Notify.WebService/Program.cs +++ b/src/Notify/StellaOps.Notify.WebService/Program.cs @@ -903,6 +903,36 @@ static void ConfigureEndpoints(WebApplication app) .RequireAuthorization(NotifyPolicies.Viewer) .RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory); + apiGroup.MapGet("/delivery/stats", async ( + IDeliveryRepository repository, + HttpContext context, + CancellationToken cancellationToken) => + { + if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) + { + return error!; + } + + var now = DateTimeOffset.UtcNow; + var stats = await repository.GetStatsAsync(tenant!, now.AddHours(-24), now, cancellationToken); + var totalCompleted = stats.Sent + stats.Delivered + stats.Failed + stats.Bounced; + var successCount = stats.Sent + stats.Delivered; + var rate = totalCompleted > 0 ? (double)successCount / totalCompleted * 100.0 : 0.0; + + return Results.Ok(new + { + totalSent = stats.Sent + stats.Delivered, + totalFailed = stats.Failed + stats.Bounced, + totalPending = stats.Pending, + successRate = Math.Round(rate, 1), + windowHours = 24, + evaluatedAt = now + }); + }) + .WithName("NotifyDeliveryStats") + .WithDescription("Get delivery statistics for the last 24 hours") + .RequireAuthorization(NotifyPolicies.Viewer); + apiGroup.MapGet("/deliveries/{deliveryId}", async ( string deliveryId, IDeliveryRepository repository, diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index 890aff508..8f0364130 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -189,6 +189,7 @@ builder.Services.AddHttpClient("AuthorityInternal", client => builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/IntegrationsReadModelService.cs b/src/Platform/StellaOps.Platform.WebService/Services/IntegrationsReadModelService.cs index e19ea79b2..cafe0e47c 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/IntegrationsReadModelService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/IntegrationsReadModelService.cs @@ -34,14 +34,14 @@ public sealed class IntegrationsReadModelService ]; private readonly IReleaseControlBundleStore bundleStore; - private readonly PlatformContextService contextService; + private readonly IPlatformContextQuery contextQuery; public IntegrationsReadModelService( IReleaseControlBundleStore bundleStore, - PlatformContextService contextService) + IPlatformContextQuery contextQuery) { this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore)); - this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService)); + this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery)); } public async Task> ListFeedsAsync( @@ -110,7 +110,7 @@ public sealed class IntegrationsReadModelService PlatformRequestContext context, CancellationToken cancellationToken) { - var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); + var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); var runs = await bundleStore.ListMaterializationRunsAsync( context.TenantId, RunScanLimit, diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs index e7439898d..b68c75c2d 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs @@ -25,7 +25,17 @@ public interface IPlatformContextStore CancellationToken cancellationToken = default); } -public sealed class PlatformContextService +public interface IPlatformContextQuery +{ + Task> GetRegionsAsync( + CancellationToken cancellationToken = default); + + Task> GetEnvironmentsAsync( + IReadOnlyList? regionFilter, + CancellationToken cancellationToken = default); +} + +public sealed class PlatformContextService : IPlatformContextQuery { private static readonly string[] AllowedTimeWindows = ["1h", "24h", "7d", "30d", "90d"]; private const string DefaultTimeWindow = "24h"; diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PostgresTranslationStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/PostgresTranslationStore.cs index c16a998d9..378408f13 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PostgresTranslationStore.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PostgresTranslationStore.cs @@ -57,16 +57,25 @@ public sealed class PostgresTranslationStore : ITranslationStore { ct.ThrowIfCancellationRequested(); var result = new Dictionary(StringComparer.Ordinal); - - await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand(SelectAllSql, conn); - cmd.Parameters.AddWithValue("tenant_id", tenantId); - cmd.Parameters.AddWithValue("locale", locale); - - await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - while (await reader.ReadAsync(ct).ConfigureAwait(false)) + try { - result[reader.GetString(0)] = reader.GetString(1); + await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(SelectAllSql, conn); + cmd.Parameters.AddWithValue("tenant_id", tenantId); + cmd.Parameters.AddWithValue("locale", locale); + + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + result[reader.GetString(0)] = reader.GetString(1); + } + } + catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex)) + { + _logger.LogWarning( + "platform.translations table is missing; returning empty translation set for tenant {TenantId} locale {Locale}", + tenantId, + locale); } return result; @@ -78,17 +87,27 @@ public sealed class PostgresTranslationStore : ITranslationStore ct.ThrowIfCancellationRequested(); var result = new Dictionary(StringComparer.Ordinal); var prefix = keyPrefix.EndsWith('.') ? keyPrefix : keyPrefix + "."; - - await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand(SelectByPrefixSql, conn); - cmd.Parameters.AddWithValue("tenant_id", tenantId); - cmd.Parameters.AddWithValue("locale", locale); - cmd.Parameters.AddWithValue("prefix", prefix + "%"); - - await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - while (await reader.ReadAsync(ct).ConfigureAwait(false)) + try { - result[reader.GetString(0)] = reader.GetString(1); + await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(SelectByPrefixSql, conn); + cmd.Parameters.AddWithValue("tenant_id", tenantId); + cmd.Parameters.AddWithValue("locale", locale); + cmd.Parameters.AddWithValue("prefix", prefix + "%"); + + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + result[reader.GetString(0)] = reader.GetString(1); + } + } + catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex)) + { + _logger.LogWarning( + "platform.translations table is missing; returning empty translation prefix set for tenant {TenantId} locale {Locale} prefix {Prefix}", + tenantId, + locale, + keyPrefix); } return result; @@ -154,17 +173,28 @@ public sealed class PostgresTranslationStore : ITranslationStore { ct.ThrowIfCancellationRequested(); var locales = new List(); - - await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); - await using var cmd = new NpgsqlCommand(SelectLocalesSql, conn); - cmd.Parameters.AddWithValue("tenant_id", tenantId); - - await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - while (await reader.ReadAsync(ct).ConfigureAwait(false)) + try { - locales.Add(reader.GetString(0)); + await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var cmd = new NpgsqlCommand(SelectLocalesSql, conn); + cmd.Parameters.AddWithValue("tenant_id", tenantId); + + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + locales.Add(reader.GetString(0)); + } + } + catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex)) + { + _logger.LogWarning( + "platform.translations table is missing; returning no DB locales for tenant {TenantId}", + tenantId); } return locales; } + + private static bool IsMissingTranslationsTable(Npgsql.PostgresException ex) + => string.Equals(ex.SqlState, "42P01", StringComparison.Ordinal); } diff --git a/src/Platform/StellaOps.Platform.WebService/Services/SecurityReadModelService.cs b/src/Platform/StellaOps.Platform.WebService/Services/SecurityReadModelService.cs index 76be34535..d176fe3b2 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/SecurityReadModelService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/SecurityReadModelService.cs @@ -22,14 +22,14 @@ public sealed class SecurityReadModelService private static readonly string[] LicenseCatalog = ["Apache-2.0", "MIT", "BUSL-1.1", "BSD-3-Clause", "MPL-2.0"]; private readonly IReleaseControlBundleStore bundleStore; - private readonly PlatformContextService contextService; + private readonly IPlatformContextQuery contextQuery; public SecurityReadModelService( IReleaseControlBundleStore bundleStore, - PlatformContextService contextService) + IPlatformContextQuery contextQuery) { this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore)); - this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService)); + this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery)); } public async Task ListFindingsAsync( @@ -204,7 +204,7 @@ public sealed class SecurityReadModelService PlatformRequestContext context, CancellationToken cancellationToken) { - var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); + var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal); var bundles = await bundleStore.ListBundlesAsync( diff --git a/src/Platform/StellaOps.Platform.WebService/Services/TopologyReadModelService.cs b/src/Platform/StellaOps.Platform.WebService/Services/TopologyReadModelService.cs index 351fb8c1d..eb9305c5f 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/TopologyReadModelService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/TopologyReadModelService.cs @@ -17,14 +17,14 @@ public sealed class TopologyReadModelService private static readonly DateTimeOffset ProjectionEpoch = DateTimeOffset.UnixEpoch; private readonly IReleaseControlBundleStore bundleStore; - private readonly PlatformContextService contextService; + private readonly IPlatformContextQuery contextQuery; public TopologyReadModelService( IReleaseControlBundleStore bundleStore, - PlatformContextService contextService) + IPlatformContextQuery contextQuery) { this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore)); - this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService)); + this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery)); } public async Task> ListRegionsAsync( @@ -248,8 +248,8 @@ public sealed class TopologyReadModelService PlatformRequestContext context, CancellationToken cancellationToken) { - var regions = await contextService.GetRegionsAsync(cancellationToken).ConfigureAwait(false); - var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); + var regions = await contextQuery.GetRegionsAsync(cancellationToken).ConfigureAwait(false); + var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false); var bundles = await bundleStore.ListBundlesAsync( context.TenantId, diff --git a/src/Platform/StellaOps.Platform.WebService/TASKS.md b/src/Platform/StellaOps.Platform.WebService/TASKS.md index b2302f669..4677e8978 100644 --- a/src/Platform/StellaOps.Platform.WebService/TASKS.md +++ b/src/Platform/StellaOps.Platform.WebService/TASKS.md @@ -46,4 +46,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | SPRINT_20260224_004-LOC-308 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: platform locale catalog endpoint (`GET /api/v1/platform/localization/locales`) is now consumed by both UI and CLI locale-selection paths. | | SPRINT_20260226_230-LOC-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_230_Platform_locale_label_translation_corrections.md`: completed non-English translation correction across Platform/Web/shared localization bundles (`bg-BG`, `de-DE`, `es-ES`, `fr-FR`, `ru-RU`, `uk-UA`, `zh-CN`, `zh-TW`), including cleanup of placeholder/transliteration/malformed values (`Ezik`, leaked token markers, mojibake) and a context-quality pass for backend German resource bundles (`graph`, `policy`, `scanner`, `advisoryai`). | | PLATFORM-223-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment.md`: shipped deterministic score explain/replay contract completion (`unknowns`, `proof_ref`, deterministic replay envelope parsing/verification differences) and updated score API/module docs with contract notes. | +| SPRINT_20260305_005-PLATFORM-BOUND-001 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: captured Platform runtime dependency inventory with explicit allowed runtime, migration-only, and prohibited coupling categories in architecture docs. | +| SPRINT_20260305_005-PLATFORM-BOUND-003 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: introduced `IPlatformContextQuery` and switched topology/security/integrations read-model services to explicit query contracts; DI now binds read-model contract separately from context mutation service. | +| SPRINT_20260305_005-PLATFORM-BOUND-004 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: documented runtime boundary policy, migration/runtime separation, and allowlisted exceptions in Platform dossiers. | diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs new file mode 100644 index 000000000..422fc8e91 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs @@ -0,0 +1,137 @@ +using StellaOps.Platform.WebService.Services; +using StellaOps.TestKit; +using System.Reflection; + +namespace StellaOps.Platform.WebService.Tests; + +public sealed class PlatformRuntimeBoundaryGuardTests +{ + private static readonly IReadOnlyDictionary ApprovedConstructorDependencies = + new Dictionary + { + [typeof(ReleaseReadModelService)] = [typeof(IReleaseControlBundleStore)], + [typeof(TopologyReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)], + [typeof(SecurityReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)], + [typeof(IntegrationsReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)], + }; + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void RuntimeReadModelServices_UseOnlyApprovedConstructorContracts() + { + var violations = new List(); + + foreach (var (serviceType, expectedDependencies) in ApprovedConstructorDependencies) + { + var constructors = serviceType + .GetConstructors(BindingFlags.Instance | BindingFlags.Public) + .Where(static ctor => !ctor.IsStatic) + .ToArray(); + + if (constructors.Length != 1) + { + violations.Add($"{serviceType.Name}: expected exactly one public constructor, found {constructors.Length}."); + continue; + } + + var actualDependencies = constructors[0] + .GetParameters() + .Select(static parameter => parameter.ParameterType) + .ToArray(); + + if (actualDependencies.Length != expectedDependencies.Length) + { + violations.Add( + $"{serviceType.Name}: expected {FormatTypeList(expectedDependencies)} but found {FormatTypeList(actualDependencies)}."); + continue; + } + + for (var i = 0; i < expectedDependencies.Length; i++) + { + if (actualDependencies[i] != expectedDependencies[i]) + { + violations.Add( + $"{serviceType.Name}: constructor contract mismatch at position {i + 1}; expected {expectedDependencies[i].FullName}, found {actualDependencies[i].FullName}."); + } + } + } + + Assert.True( + violations.Count == 0, + "Runtime read-model constructor contracts drifted from approved boundary.\n" + + string.Join(Environment.NewLine, violations)); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void RuntimeSourceFiles_DoNotReferenceForeignPersistenceOutsideAllowlist() + { + var platformRoot = FindPlatformRoot(); + var scannedRoots = new[] + { + Path.Combine(platformRoot, "StellaOps.Platform.WebService"), + Path.Combine(platformRoot, "__Libraries", "StellaOps.Platform.Database") + }; + + var allowlistedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + NormalizePath(Path.Combine("StellaOps.Platform.WebService", "Endpoints", "SeedEndpoints.cs")), + NormalizePath(Path.Combine("__Libraries", "StellaOps.Platform.Database", "MigrationModulePlugins.cs")), + }; + + var violations = new List(); + + foreach (var root in scannedRoots) + { + foreach (var file in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories)) + { + var relativePath = NormalizePath(Path.GetRelativePath(platformRoot, file)); + + foreach (var (line, lineNumber) in File.ReadLines(file).Select((value, index) => (value, index + 1))) + { + if (!line.TrimStart().StartsWith("using StellaOps.", StringComparison.Ordinal) + || !line.Contains(".Persistence", StringComparison.Ordinal) + || allowlistedFiles.Contains(relativePath)) + { + continue; + } + + violations.Add($"{relativePath}:{lineNumber}: {line.Trim()}"); + } + } + } + + Assert.True( + violations.Count == 0, + "Foreign persistence references are only allowed in explicit migration/seed boundaries. Violations:\n" + + string.Join(Environment.NewLine, violations)); + } + + private static string FormatTypeList(IEnumerable types) + { + return string.Join(", ", types.Select(static type => type.FullName ?? type.Name)); + } + + private static string FindPlatformRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine(current.FullName, "src", "Platform"); + if (Directory.Exists(Path.Combine(candidate, "StellaOps.Platform.WebService")) + && Directory.Exists(Path.Combine(candidate, "__Libraries", "StellaOps.Platform.Database"))) + { + return candidate; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate src/Platform root for runtime boundary guard tests."); + } + + private static string NormalizePath(string path) + { + return path.Replace('\\', '/'); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md index 4d5c084b7..133842bf4 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md @@ -23,3 +23,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | SPRINT_20260224_004-LOC-302-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added language preference endpoint coverage in `PreferencesEndpointsTests` (round-trip persistence + invalid locale rejection) and expanded locale catalog verification in `LocalizationEndpointsTests`. | | SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. | | SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). | +| SPRINT_20260305_005-PLATFORM-BOUND-002 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and disallow foreign persistence references outside explicit migration/seed allowlist files. | diff --git a/src/Remediation/AGENTS.md b/src/Remediation/AGENTS.md index c17880aac..c4c650546 100644 --- a/src/Remediation/AGENTS.md +++ b/src/Remediation/AGENTS.md @@ -19,7 +19,7 @@ - `docs/07_HIGH_LEVEL_ARCHITECTURE.md` - `docs/modules/platform/architecture.md` - `docs/modules/remediation/architecture.md` -- `docs/implplan/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md` +- `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md` ## Working Agreement - Update sprint task state (`TODO`, `DOING`, `DONE`, `BLOCKED`) in `docs/implplan/SPRINT_*.md` as work progresses. diff --git a/src/Replay/StellaOps.Replay.WebService/TASKS.md b/src/Replay/StellaOps.Replay.WebService/TASKS.md index f4b582c93..7ef596939 100644 --- a/src/Replay/StellaOps.Replay.WebService/TASKS.md +++ b/src/Replay/StellaOps.Replay.WebService/TASKS.md @@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Replay/StellaOps.Replay.WebService/StellaOps.Replay.WebService.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. | +| SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. | diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs index 2438ee4ef..2df61f85b 100644 --- a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/FeedSnapshots/PointInTimeQueryApiIntegrationTests.cs @@ -4,11 +4,20 @@ using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using StellaOps.Replay.WebService; using Xunit; @@ -23,6 +32,7 @@ public sealed class PointInTimeQueryApiIntegrationTests { await using var factory = CreateFactory(); using var client = factory.CreateClient(); + ConfigureClient(client); var cveId = "CVE-2024-7777"; var providerId = "nvd-e2e"; @@ -112,6 +122,7 @@ public sealed class PointInTimeQueryApiIntegrationTests { await using var factory = CreateFactory(); using var client = factory.CreateClient(); + ConfigureClient(client); var response = await client.PostAsJsonAsync( "/v1/pit/advisory/diff", @@ -146,6 +157,67 @@ public sealed class PointInTimeQueryApiIntegrationTests ["Replay:Authority:RequireHttpsMetadata"] = "false", }); }); + builder.ConfigureTestServices(services => + { + services.AddAuthentication(TestReplayAuthHandler.SchemeName) + .AddScheme( + TestReplayAuthHandler.SchemeName, + _ => { }); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName; + options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName; + options.DefaultScheme = TestReplayAuthHandler.SchemeName; + }); + + services.RemoveAll(); + services.AddSingleton(); + }); }); } + + private static void ConfigureClient(HttpClient client) + { + client.DefaultRequestHeaders.TryAddWithoutValidation("X-StellaOps-Tenant", "tenant-e2e"); + } + + private sealed class TestReplayAuthHandler : AuthenticationHandler + { + public const string SchemeName = "ReplayTestScheme"; + + public TestReplayAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim("scope", "vuln.operate replay.token.read replay.token.write"), + new Claim("scp", "vuln.operate replay.token.read replay.token.write") + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName)); + var ticket = new AuthenticationTicket(principal, SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + private sealed class AllowAllAuthorizationHandler : IAuthorizationHandler + { + public Task HandleAsync(AuthorizationHandlerContext context) + { + foreach (var requirement in context.PendingRequirements.ToList()) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } } diff --git a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md index c4368f2a7..e13055f25 100644 --- a/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md +++ b/src/Replay/__Tests/StellaOps.Replay.Core.Tests/TASKS.md @@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). | +| SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). | diff --git a/src/Router/StellaOps.Gateway.WebService/TASKS.md b/src/Router/StellaOps.Gateway.WebService/TASKS.md index 397ad53ae..7aa72e965 100644 --- a/src/Router/StellaOps.Gateway.WebService/TASKS.md +++ b/src/Router/StellaOps.Gateway.WebService/TASKS.md @@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | RGH-01 | DONE | 2026-02-22: Added SPA fallback handling for browser deep links on microservice route matches; API prefixes remain backend-dispatched. | | RGH-02 | DONE | 2026-03-04: Expanded approved auth passthrough prefixes (`/authority`, `/doctor`, `/api`) to unblock authenticated gateway routes used by Audit Log UI E2E. | +| RGH-03 | DONE | 2026-03-05: Aligned `/api/v1/search*` and `/api/v1/advisory-ai*` route translations to AdvisoryAI `/v1/*`, added compose/runtime parity safeguards, and verified setup smoke coverage. | diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 1151c7551..1cf1422ba 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -102,7 +102,8 @@ { "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/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai" }, + { "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/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" }, { "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" }, diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs new file mode 100644 index 000000000..8f0f2889c --- /dev/null +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json; + +namespace StellaOps.Gateway.WebService.Tests.Configuration; + +public sealed class GatewayRouteSearchMappingsTests +{ + private static readonly (string Path, string Target, string RouteType)[] 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") + ]; + + public static TheoryData RouteConfigPaths => new() + { + "src/Router/StellaOps.Gateway.WebService/appsettings.json", + "devops/compose/router-gateway-local.json", + "devops/compose/router-gateway-local.reverseproxy.json" + }; + + [Theory] + [MemberData(nameof(RouteConfigPaths))] + public void RouteTable_ContainsUnifiedSearchMappingsAndKeepsThemAheadOfApiCatchAll(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, index) => new RouteEntry( + index, + route.GetProperty("Type").GetString() ?? string.Empty, + route.GetProperty("Path").GetString() ?? string.Empty, + route.TryGetProperty("TranslatesTo", out var translatesTo) + ? translatesTo.GetString() ?? string.Empty + : string.Empty)) + .ToList(); + + var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal)); + + foreach (var (requiredPath, requiredTarget, requiredType) 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); + + if (catchAllIndex >= 0) + { + Assert.True(route.Index < catchAllIndex, $"{requiredPath} must appear before /api catch-all in {configRelativePath}."); + } + } + } + + private static string FindRepositoryRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + { + return current.FullName; + } + } + + throw new InvalidOperationException($"Unable to locate repository root from {AppContext.BaseDirectory}."); + } + + private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo); +} diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md index b073e5e1c..63bbd75ba 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0349-A | DONE | Waived (test project; revalidated 2026-01-07). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. | +| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. | diff --git a/src/SbomService/StellaOps.SbomService/Program.cs b/src/SbomService/StellaOps.SbomService/Program.cs index 3a03994a7..33706c0b4 100644 --- a/src/SbomService/StellaOps.SbomService/Program.cs +++ b/src/SbomService/StellaOps.SbomService/Program.cs @@ -1540,6 +1540,21 @@ app.MapPost("/internal/orchestrator/watermarks", async Task ( .RequireAuthorization(SbomPolicies.Internal) .RequireTenant(); +// Compare baseline recommendation stub (called by UI compare feature) +app.MapGet("/api/compare/baselines/{scanDigest}", (string scanDigest) => + Results.Ok(new + { + selectedDigest = (string?)null, + selectionReason = "No baseline recommendations available for this scan", + alternatives = Array.Empty(), + autoSelectEnabled = true, + scanDigest + })) + .WithName("GetCompareBaselineRecommendation") + .WithDescription("Returns baseline scan recommendations for delta comparison. Returns empty recommendations when no previous scans are available.") + .WithTags("Compare") + .RequireAuthorization(SbomPolicies.Read); + app.TryRefreshStellaRouterEndpoints(routerEnabled); app.Run(); diff --git a/src/Web/StellaOps.Web/output/playwright/header-search-repro.png b/src/Web/StellaOps.Web/output/playwright/header-search-repro.png new file mode 100644 index 000000000..43855c018 Binary files /dev/null and b/src/Web/StellaOps.Web/output/playwright/header-search-repro.png differ diff --git a/src/Web/StellaOps.Web/output/playwright/inspect-stella-ops-local-load.cjs b/src/Web/StellaOps.Web/output/playwright/inspect-stella-ops-local-load.cjs new file mode 100644 index 000000000..3333c87ea --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/inspect-stella-ops-local-load.cjs @@ -0,0 +1,101 @@ +const { chromium } = require('playwright'); + +const session = { + subjectId: 'user-author', + tenant: 'tenant-default', + scopes: [ + 'ui.read', + 'policy:read', + 'policy:author', + 'policy:simulate', + 'advisory-ai:view', + 'advisory-ai:operate', + 'findings:read', + 'vex:read', + 'admin', + ], +}; + +(async () => { + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--no-proxy-server'], + }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + const navHistory = []; + const httpErrors = []; + const failures = []; + let currentUrl = ''; + + page.on('framenavigated', (frame) => { + if (frame !== page.mainFrame()) { + return; + } + + const entry = `${new Date().toISOString()} ${frame.url()}`; + navHistory.push(entry); + console.log('[nav]', entry); + }); + + page.on('response', (response) => { + if (response.status() < 400) { + return; + } + + const request = response.request(); + const entry = `${response.status()} ${request.method()} ${response.url()}`; + httpErrors.push(entry); + console.log('[http-error]', entry); + }); + + page.on('requestfailed', (request) => { + const entry = `${request.method()} ${request.url()} :: ${request.failure()?.errorText ?? 'failed'}`; + failures.push(entry); + console.log('[requestfailed]', entry); + }); + + page.on('console', (msg) => { + if (msg.type() === 'error') { + console.log('[console-error]', msg.text()); + } + }); + + await page.addInitScript((stubSession) => { + window.__stellaopsTestSession = stubSession; + }, session); + + const target = process.argv[2] ?? 'https://stella-ops.local/'; + console.log('[goto]', target); + + try { + await page.goto(target, { waitUntil: 'commit', timeout: 20000 }); + } catch (error) { + console.log('[goto-error]', error.message); + } + + for (let i = 0; i < 20; i += 1) { + const url = page.url(); + if (url !== currentUrl) { + currentUrl = url; + console.log('[url-change]', url); + } + + await page.waitForTimeout(1000); + } + + const searchInputCount = await page + .evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length) + .catch(() => -1); + + console.log('[final-url]', page.url()); + console.log('[title]', await page.title().catch(() => '')); + console.log('[search-input-count]', searchInputCount); + console.log('[nav-count]', navHistory.length); + console.log('[http-error-count]', httpErrors.length); + console.log('[failed-request-count]', failures.length); + + await page.screenshot({ path: 'output/playwright/stella-ops-local-load-check-viewport.png' }); + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs b/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs new file mode 100644 index 000000000..2774d1e31 --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs @@ -0,0 +1,66 @@ +const { chromium } = require('playwright'); + +const session = { + subjectId: 'user-author', + tenant: 'tenant-default', + scopes: ['ui.read', 'policy:read', 'policy:author', 'policy:simulate', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', 'admin'], +}; + +(async () => { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (url.includes('/search')) { + console.log('[requestfailed]', request.method(), url, request.failure()?.errorText); + } + }); + + page.on('response', (response) => { + const url = response.url(); + if ( + url.includes('/api/v1/search/query') || + url.includes('/api/v1/advisory-ai/search') || + url.includes('/api/v1/advisory-ai/search/analytics') + ) { + const req = response.request(); + console.log('[response]', req.method(), response.status(), url); + } + }); + + await page.addInitScript((stubSession) => { + window.__stellaopsTestSession = stubSession; + }, session); + + const url = process.argv[2] || 'https://127.1.0.1/'; + console.log('[goto]', url); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForTimeout(2000); + + const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length); + console.log('[search-input-count]', count); + + if (count === 0) { + console.log('[page-url]', page.url()); + console.log('[title]', await page.title()); + await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true }); + await browser.close(); + process.exit(1); + } + + await page.click('app-global-search input[type="text"]', { timeout: 15000 }); + await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 }); + await page.waitForTimeout(3000); + + const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length); + const emptyText = await page.locator('.search__empty').allTextContents(); + const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false); + console.log('[entity-cards]', results); + console.log('[empty-text]', emptyText.join(' | ')); + console.log('[degraded-banner]', degradedVisible); + + await page.screenshot({ path: 'output/playwright/header-search-repro-live.png', fullPage: true }); + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs b/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs new file mode 100644 index 000000000..12c2fb266 --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs @@ -0,0 +1,66 @@ +const { chromium } = require('playwright'); + +const session = { + subjectId: 'user-author', + tenant: 'tenant-default', + scopes: ['ui.read','policy:read','policy:author','policy:simulate','advisory:search','advisory:read','search:read','findings:read','vex:read','admin'] +}; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (url.includes('/search')) { + console.log('[requestfailed]', request.method(), url, request.failure()?.errorText); + } + }); + + page.on('response', (response) => { + const url = response.url(); + if ( + url.includes('/api/v1/search/query') || + url.includes('/api/v1/advisory-ai/search') || + url.includes('/api/v1/advisory-ai/search/analytics') + ) { + const req = response.request(); + console.log('[response]', req.method(), response.status(), url); + } + }); + + await page.addInitScript((stubSession) => { + window.__stellaopsTestSession = stubSession; + }, session); + + const url = process.argv[2] || 'https://127.1.0.1:10000/'; + console.log('[goto]', url); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForTimeout(2000); + + const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length); + console.log('[search-input-count]', count); + + if (count === 0) { + console.log('[page-url]', page.url()); + console.log('[title]', await page.title()); + await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true }); + await browser.close(); + process.exit(1); + } + + await page.click('app-global-search input[type="text"]', { timeout: 15000 }); + await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 }); + await page.waitForTimeout(3000); + + const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length); + const emptyText = await page.locator('.search__empty').allTextContents(); + const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false); + console.log('[entity-cards]', results); + console.log('[empty-text]', emptyText.join(' | ')); + console.log('[degraded-banner]', degradedVisible); + + await page.screenshot({ path: 'output/playwright/header-search-repro.png', fullPage: true }); + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts index 8719ac565..174b62c8e 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts @@ -101,7 +101,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { private readonly http = inject(HttpClient); private readonly context = inject(PlatformContextStore); private readonly readBaseUrl = '/api/v2/releases'; - private readonly legacyBaseUrl = '/api/release-orchestrator/releases'; + private readonly legacyBaseUrl = '/api/v1/releases'; listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> { const page = Math.max(1, filter?.page ?? 1); diff --git a/src/Web/StellaOps.Web/src/app/core/config/config.guard.spec.ts b/src/Web/StellaOps.Web/src/app/core/config/config.guard.spec.ts index 2b551bb69..68c7b4546 100644 --- a/src/Web/StellaOps.Web/src/app/core/config/config.guard.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/config/config.guard.spec.ts @@ -61,7 +61,7 @@ describe('requireConfigGuard', () => { expect((result as UrlTree).toString()).toBe('/setup'); }); - it('should redirect to /setup/wizard when config loaded but setup is absent', () => { + it('should redirect to /setup-wizard/wizard when config loaded but setup is absent', () => { (configService.isConfigured as jasmine.Spy).and.returnValue(true); Object.defineProperty(configService, 'config', { get: () => ({ ...minimalConfig, setup: undefined }), @@ -70,10 +70,10 @@ describe('requireConfigGuard', () => { const result = runGuard(); expect(result).toBeInstanceOf(UrlTree); - expect((result as UrlTree).toString()).toBe('/setup/wizard'); + expect((result as UrlTree).toString()).toBe('/setup-wizard/wizard'); }); - it('should redirect to /setup/wizard?resume=migrations when setup is a step ID', () => { + it('should redirect to /setup-wizard/wizard?resume=migrations when setup is a step ID', () => { (configService.isConfigured as jasmine.Spy).and.returnValue(true); Object.defineProperty(configService, 'config', { get: () => ({ ...minimalConfig, setup: 'migrations' }), @@ -82,7 +82,7 @@ describe('requireConfigGuard', () => { const result = runGuard(); expect(result).toBeInstanceOf(UrlTree); - expect((result as UrlTree).toString()).toContain('/setup/wizard'); + expect((result as UrlTree).toString()).toContain('/setup-wizard/wizard'); expect((result as UrlTree).queryParams['resume']).toBe('migrations'); }); diff --git a/src/Web/StellaOps.Web/src/app/core/config/config.guard.ts b/src/Web/StellaOps.Web/src/app/core/config/config.guard.ts index 76634074f..48d15933d 100644 --- a/src/Web/StellaOps.Web/src/app/core/config/config.guard.ts +++ b/src/Web/StellaOps.Web/src/app/core/config/config.guard.ts @@ -7,8 +7,8 @@ import { AppConfigService } from './app-config.service'; * Route guard that checks both configuration loading and setup state. * * - If config is not loaded → redirect to /setup - * - If config is loaded but `setup` is absent/undefined → redirect to /setup/wizard (fresh install) - * - If config is loaded and `setup` is a step ID → redirect to /setup/wizard?resume=<stepId> + * - If config is loaded but `setup` is absent/undefined → redirect to /setup-wizard/wizard (fresh install) + * - If config is loaded and `setup` is a step ID → redirect to /setup-wizard/wizard?resume=<stepId> * - If config is loaded and `setup === "complete"` → allow navigation * * Place this guard **before** auth guards so unconfigured deployments @@ -26,12 +26,12 @@ export const requireConfigGuard: CanMatchFn = () => { if (!setup) { // setup absent → fresh install, go to wizard - return router.createUrlTree(['/setup/wizard']); + return router.createUrlTree(['/setup-wizard/wizard']); } if (setup !== 'complete') { // setup = stepId → resume wizard at that step - return router.createUrlTree(['/setup/wizard'], { + return router.createUrlTree(['/setup-wizard/wizard'], { queryParams: { resume: setup }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts index 41afeeba0..4f9ce824b 100644 --- a/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/administration/administration-overview.component.ts @@ -21,79 +21,142 @@ interface SetupCard { template: ` <div class="admin-overview"> <header class="admin-overview__header"> - <h1 class="admin-overview__title">Setup</h1> - <p class="admin-overview__subtitle"> - Manage topology, identity, tenants, notifications, and system controls. - </p> + <div> + <h1 class="admin-overview__title">Setup</h1> + <p class="admin-overview__subtitle"> + Manage topology, identity, tenants, notifications, and system controls. + </p> + </div> + <div class="admin-overview__meta"> + <span class="admin-overview__meta-chip">7 setup domains</span> + <span class="admin-overview__meta-chip">3 operational drilldowns</span> + <span class="admin-overview__meta-chip">Offline-first safe</span> + </div> </header> - <div class="admin-overview__grid"> - @for (card of cards; track card.id) { - <a class="admin-card" [routerLink]="card.route"> - <div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div> - <div class="admin-card__body"> - <h2 class="admin-card__title">{{ card.title }}</h2> - <p class="admin-card__description">{{ card.description }}</p> - </div> - </a> - } - </div> + <div class="admin-overview__layout"> + <div> + <div class="admin-overview__grid"> + @for (card of cards; track card.id) { + <a class="admin-card" [routerLink]="card.route"> + <div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div> + <div class="admin-card__body"> + <h2 class="admin-card__title">{{ card.title }}</h2> + <p class="admin-card__description">{{ card.description }}</p> + </div> + </a> + } + </div> - <section class="admin-overview__drilldowns"> - <h2 class="admin-overview__section-heading">Operational Drilldowns</h2> - <ul class="admin-overview__links"> - <li><a routerLink="/ops/operations/quotas">Quotas & Limits</a> - Ops</li> - <li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li> - <li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li> - </ul> - </section> + <section class="admin-overview__drilldowns"> + <h2 class="admin-overview__section-heading">Operational Drilldowns</h2> + <ul class="admin-overview__links"> + <li><a routerLink="/ops/operations/quotas">Quotas & Limits</a> - Ops</li> + <li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li> + <li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li> + </ul> + </section> + </div> + + <aside class="admin-overview__aside"> + <section class="admin-overview__aside-section"> + <h2 class="admin-overview__section-heading">Quick Actions</h2> + <div class="admin-overview__quick-actions"> + <a routerLink="/setup/topology/targets">Add Target</a> + <a routerLink="/setup/integrations">Configure Integrations</a> + <a routerLink="/setup/identity-access">Review Access</a> + </div> + </section> + + <section class="admin-overview__aside-section"> + <h2 class="admin-overview__section-heading">Suggested Next Steps</h2> + <ul class="admin-overview__links admin-overview__links--compact"> + <li>Validate environment mapping and target ownership.</li> + <li>Verify notification channels before first production promotion.</li> + <li>Confirm audit trail visibility for approvers.</li> + </ul> + </section> + </aside> + </div> </div> `, styles: [ ` .admin-overview { padding: 1.5rem; - max-width: 1200px; + max-width: 1240px; } .admin-overview__header { - margin-bottom: 2rem; + margin-bottom: 1.5rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; } .admin-overview__title { - font-size: 1.5rem; - font-weight: 600; + font-size: 1.9rem; + line-height: 1.05; + font-weight: 700; margin: 0 0 0.5rem; } .admin-overview__subtitle { - color: var(--color-text-secondary, #666); + color: var(--color-text-secondary, #4f4b3e); margin: 0; + max-width: 60ch; + } + + .admin-overview__meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .admin-overview__meta-chip { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-secondary, #d4c9a8); + border-radius: var(--radius-full, 999px); + padding: 0.24rem 0.55rem; + font-size: 0.65rem; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--color-text-secondary, #4f4b3e); + background: var(--color-surface-primary, #fff); + } + + .admin-overview__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 1rem; } .admin-overview__grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1rem; - margin-bottom: 2rem; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 0.85rem; + margin-bottom: 1.5rem; } .admin-card { display: flex; align-items: flex-start; gap: 1rem; - padding: 1.25rem; + padding: 1rem; background: var(--color-surface, #fff); border: 1px solid var(--color-border, #e5e7eb); border-radius: var(--radius-md, 8px); text-decoration: none; color: inherit; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; } .admin-card:hover { - border-color: var(--color-brand-primary, #4f46e5); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: var(--color-brand-primary, #f5a623); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); } .admin-card__icon { @@ -104,24 +167,59 @@ interface SetupCard { } .admin-card__title { - font-size: 0.9375rem; - font-weight: 600; + font-size: 0.94rem; + font-weight: 700; margin: 0 0 0.25rem; } .admin-card__description { - font-size: 0.8125rem; - color: var(--color-text-secondary, #666); + font-size: 0.82rem; + color: var(--color-text-secondary, #4f4b3e); margin: 0; } + .admin-overview__aside { + display: grid; + gap: 0.8rem; + align-content: start; + } + + .admin-overview__aside-section, + .admin-overview__drilldowns { + border: 1px solid var(--color-border-primary, #e5e7eb); + background: var(--color-surface-primary, #fff); + border-radius: var(--radius-md, 8px); + padding: 0.9rem; + } + .admin-overview__section-heading { - font-size: 0.875rem; - font-weight: 600; - margin: 0 0 0.75rem; + font-size: 0.74rem; + font-weight: 700; + margin: 0 0 0.65rem; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary, #666); + letter-spacing: 0.08em; + color: var(--color-text-secondary, #4f4b3e); + } + + .admin-overview__quick-actions { + display: grid; + gap: 0.45rem; + } + + .admin-overview__quick-actions a { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-primary, #e5e7eb); + border-radius: var(--radius-sm, 6px); + padding: 0.45rem 0.55rem; + text-decoration: none; + color: var(--color-text-primary, #1c1200); + background: var(--color-surface-secondary, #faf8f0); + } + + .admin-overview__quick-actions a:hover { + border-color: var(--color-brand-primary, #f5a623); + color: var(--color-brand-primary, #f5a623); } .admin-overview__links { @@ -134,18 +232,42 @@ interface SetupCard { } .admin-overview__links li { - font-size: 0.875rem; - color: var(--color-text-secondary, #666); + font-size: 0.84rem; + color: var(--color-text-secondary, #4f4b3e); + } + + .admin-overview__links--compact li { + line-height: 1.35; } .admin-overview__links a { - color: var(--color-brand-primary, #4f46e5); + color: var(--color-brand-primary, #f5a623); text-decoration: none; } .admin-overview__links a:hover { text-decoration: underline; } + + @media (max-width: 1080px) { + .admin-overview__layout { + grid-template-columns: 1fr; + } + + .admin-overview__header { + flex-direction: column; + } + } + + @media (max-width: 575px) { + .admin-overview { + padding: 0.75rem; + } + + .admin-overview__title { + font-size: 2rem; + } + } `, ], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.scss b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.scss index 67a774176..0f62acc51 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.scss @@ -4,13 +4,18 @@ display: flex; flex-direction: column; height: 100%; + min-height: 0; } .compare-toolbar { display: flex; justify-content: space-between; + align-items: flex-start; + height: auto; + min-height: 0; padding: var(--space-2) var(--space-4); background: var(--color-surface-secondary); + overflow: visible; .target-selector { display: flex; @@ -86,6 +91,7 @@ display: flex; flex: 1; overflow: hidden; + min-height: 0; } .pane { @@ -187,6 +193,120 @@ } } +@media (max-width: 1080px) { + .compare-toolbar { + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space-2); + + .target-selector { + flex: 1 1 100%; + min-width: 0; + flex-wrap: wrap; + } + + .toolbar-actions { + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + } + } + + .delta-summary { + flex-wrap: wrap; + gap: var(--space-2); + } +} + +@media (max-width: 768px) { + .compare-view { + height: auto; + min-height: 100%; + } + + .compare-toolbar { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + padding: var(--space-2); + gap: var(--space-2); + + .target-selector { + width: 100%; + gap: var(--space-2); + align-items: flex-start; + + mat-select { + min-width: 0; + width: 100%; + } + } + + .toolbar-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: var(--space-2); + + > button[mat-icon-button] { + justify-self: start; + } + + > button[mat-stroked-button] { + justify-self: end; + } + } + + .role-toggle { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-1); + } + + .role-toggle-button { + min-width: 0; + width: 100%; + padding-inline: 0.2rem; + font-size: 0.74rem; + } + } + + .delta-summary { + gap: var(--space-1); + padding: var(--space-2); + + .summary-chip { + font-size: 0.75rem; + } + } + + .panes-container { + flex-direction: column; + overflow: visible; + } + + .pane { + border-right: none; + border-bottom: 1px solid var(--color-border-primary); + } + + .pane:last-child { + border-bottom: none; + } + + .categories-pane, + .items-pane, + .evidence-pane { + width: 100%; + } + + .evidence-pane .side-by-side { + grid-template-columns: 1fr; + } +} + .empty-state { display: flex; flex-direction: column; diff --git a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts index 51da98a7f..2d58d694c 100644 --- a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts @@ -31,7 +31,7 @@ interface Deployment { </div> </header> - <div class="table-container"> + <div class="table-container table-container--desktop"> <table class="data-table"> <thead> <tr> @@ -76,6 +76,53 @@ interface Deployment { </tbody> </table> </div> + + <div class="deployment-cards" aria-label="Deployment cards"> + @for (deployment of deployments(); track deployment.id) { + <article class="deployment-card"> + <header class="deployment-card__header"> + <a [routerLink]="['./', deployment.id]" class="deployment-link deployment-card__id"> + {{ deployment.id }} + </a> + <span class="status-badge" [class]="'status-badge--' + deployment.status"> + @if (deployment.status === 'running') { + <span class="spinner"></span> + } + {{ deployment.status | uppercase }} + </span> + </header> + + <dl class="deployment-card__meta"> + <div> + <dt>Release</dt> + <dd> + <a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a> + </dd> + </div> + <div> + <dt>Environment</dt> + <dd>{{ deployment.environment }}</dd> + </div> + <div> + <dt>Started</dt> + <dd>{{ deployment.startedAt }}</dd> + </div> + <div> + <dt>Duration</dt> + <dd>{{ deployment.duration }}</dd> + </div> + <div> + <dt>Initiated By</dt> + <dd>{{ deployment.initiatedBy }}</dd> + </div> + </dl> + + <div class="deployment-card__actions"> + <a [routerLink]="['./', deployment.id]" class="btn btn--sm">View Deployment</a> + </div> + </article> + } + </div> </div> `, styles: [` @@ -88,9 +135,10 @@ interface Deployment { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; } - .data-table { width: 100%; border-collapse: collapse; } + .data-table { width: 100%; min-width: 840px; border-collapse: collapse; } .data-table th, .data-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } .data-table th { background: var(--color-surface-secondary); font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; } .data-table tbody tr:hover { background: var(--color-nav-hover); } @@ -115,6 +163,74 @@ interface Deployment { @keyframes spin { to { transform: rotate(360deg); } } .btn { padding: 0.25rem 0.5rem; border-radius: var(--radius-md); font-size: 0.75rem; font-weight: var(--font-weight-medium); text-decoration: none; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); } + + .deployment-cards { + display: none; + gap: 0.6rem; + } + + .deployment-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.7rem; + display: grid; + gap: 0.6rem; + } + + .deployment-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .deployment-card__id { + font-size: 0.84rem; + } + + .deployment-card__meta { + margin: 0; + display: grid; + gap: 0.35rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .deployment-card__meta div { + display: grid; + gap: 0.1rem; + } + + .deployment-card__meta dt { + margin: 0; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + } + + .deployment-card__meta dd { + margin: 0; + font-size: 0.84rem; + color: var(--color-text-primary); + line-height: 1.25; + } + + .deployment-card__actions { + display: flex; + justify-content: flex-end; + } + + @media (max-width: 768px) { + .deployments-page { max-width: none; } + .page-title { font-size: 2rem; line-height: 1.05; } + .page-subtitle { font-size: 0.95rem; line-height: 1.35; } + .data-table th, .data-table td { padding: 0.625rem 0.75rem; } + .table-container--desktop { display: none; } + .deployment-cards { display: grid; } + .deployment-card__meta { grid-template-columns: 1fr; } + .deployment-card__actions .btn { width: 100%; text-align: center; } + } `] }) export class DeploymentsListPageComponent { diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts b/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts index e2ade6fc8..974e9cbdd 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts @@ -92,5 +92,5 @@ export function getCheckIdsForStep(stepId: SetupStepId): string[] { /** Build a deep-link URL to the setup wizard for a specific step in reconfigure mode. */ export function buildWizardDeepLink(stepId: SetupStepId): string { - return `/setup/wizard?step=${stepId}&mode=reconfigure`; + return `/setup-wizard/wizard?step=${stepId}&mode=reconfigure`; } diff --git a/src/Web/StellaOps.Web/src/app/features/ops/ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/ops/ops-overview-page.component.ts index 73d4f9b35..0aaeee167 100644 --- a/src/Web/StellaOps.Web/src/app/features/ops/ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/ops/ops-overview-page.component.ts @@ -13,22 +13,136 @@ import { RouterLink } from '@angular/router'; <p>Unified operations workspace for platform runtime, policy governance, and integrations.</p> </header> - <div class="doors"> - <a routerLink="/ops/operations">Operations</a> - <a routerLink="/ops/integrations">Integrations</a> - <a routerLink="/ops/policy">Policy</a> - <a routerLink="/ops/platform-setup">Platform Setup</a> + <div class="ops-overview__layout"> + <div class="doors"> + <a routerLink="/ops/operations"> + <strong>Operations</strong> + <span>Scheduler, feeds, health, quotas, and runtime controls.</span> + </a> + <a routerLink="/ops/integrations"> + <strong>Integrations</strong> + <span>Connector onboarding, source registration, and delivery channels.</span> + </a> + <a routerLink="/ops/policy"> + <strong>Policy</strong> + <span>Governance, simulations, waivers, and gate catalog management.</span> + </a> + <a routerLink="/ops/platform-setup"> + <strong>Platform Setup</strong> + <span>Environment bootstrap, topology alignment, and baseline controls.</span> + </a> + </div> + + <aside class="ops-overview__aside"> + <h2>Operational Focus</h2> + <ul> + <li>Validate feed freshness and advisory import health.</li> + <li>Review scheduler backlog and failed task retries.</li> + <li>Confirm policy package rollout and waiver expiration queue.</li> + </ul> + </aside> </div> </section> `, styles: [ ` - .ops-overview { display: grid; gap: 1rem; } - h1 { margin: 0; font-size: 1.35rem; } - p { margin: 0; color: var(--color-text-secondary); } - .doors { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } - .doors a { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.65rem; text-decoration: none; color: var(--color-text-primary); background: var(--color-surface-primary); } - .doors a:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); } + .ops-overview { + display: grid; + gap: 1rem; + } + + h1 { + margin: 0; + font-size: 2rem; + line-height: 1.05; + } + + p { + margin: 0.3rem 0 0; + color: var(--color-text-secondary); + max-width: 70ch; + } + + .ops-overview__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 1rem; + } + + .doors { + display: grid; + gap: 0.7rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .doors a { + display: grid; + gap: 0.35rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + padding: 0.75rem; + text-decoration: none; + color: var(--color-text-primary); + background: var(--color-surface-primary); + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + } + + .doors a strong { + font-size: 0.95rem; + line-height: 1.2; + } + + .doors a span { + font-size: 0.78rem; + line-height: 1.35; + color: var(--color-text-secondary); + } + + .doors a:hover { + border-color: var(--color-brand-primary); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); + } + + .ops-overview__aside { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.8rem; + } + + .ops-overview__aside h2 { + margin: 0 0 0.55rem; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-secondary); + } + + .ops-overview__aside ul { + margin: 0; + padding-left: 1rem; + display: grid; + gap: 0.35rem; + } + + .ops-overview__aside li { + font-size: 0.82rem; + line-height: 1.35; + color: var(--color-text-secondary); + } + + @media (max-width: 1080px) { + .ops-overview__layout { + grid-template-columns: 1fr; + } + } + + @media (max-width: 575px) { + h1 { + font-size: 2rem; + } + } `, ], }) diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/setup-wizard.routes.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/setup-wizard.routes.ts index f9246e5a2..8bbce35a5 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/setup-wizard.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/setup-wizard.routes.ts @@ -3,7 +3,7 @@ * @sprint Sprint 4: UI Wizard Core * @description Routes for the setup wizard feature. * The default path shows the config-missing screen (for unconfigured deployments). - * The /setup/wizard path loads the full setup wizard (when config is present). + * The /setup-wizard/wizard path loads the full setup wizard (when config is present). */ import { Routes } from '@angular/router'; diff --git a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts index f221a9fa9..5703e34ae 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; @@ -6,6 +6,7 @@ import { AppTopbarComponent } from '../app-topbar/app-topbar.component'; import { AppSidebarComponent } from '../app-sidebar'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { OverlayHostComponent } from '../overlay-host/overlay-host.component'; +import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service'; /** * AppShellComponent - Main application shell with permanent left rail navigation. @@ -29,16 +30,16 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component'; OverlayHostComponent ], template: ` - <div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarCollapsed()"> + <div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()"> <!-- Skip link for accessibility --> <a class="shell__skip-link" href="#main-content">Skip to main content</a> <!-- Sidebar --> <app-sidebar class="shell__sidebar" - [collapsed]="sidebarCollapsed()" + [collapsed]="sidebarPrefs.sidebarCollapsed()" (mobileClose)="onMobileSidebarClose()" - (collapseToggle)="onSidebarCollapseToggle()" + (collapseToggle)="sidebarPrefs.toggleSidebar()" ></app-sidebar> <!-- Main area (topbar + content) --> @@ -212,12 +213,11 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppShellComponent { + readonly sidebarPrefs = inject(SidebarPreferenceService); + /** Whether mobile menu is open */ readonly mobileMenuOpen = signal(false); - /** Whether sidebar is collapsed (icons only) */ - readonly sidebarCollapsed = signal(false); - onMobileMenuToggle(): void { this.mobileMenuOpen.update((v) => !v); } @@ -225,8 +225,4 @@ export class AppShellComponent { onMobileSidebarClose(): void { this.mobileMenuOpen.set(false); } - - onSidebarCollapseToggle(): void { - this.sidebarCollapsed.update((v) => !v); - } } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts index 3f6ec7f82..da0208d08 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts @@ -39,6 +39,25 @@ describe('AppSidebarComponent', () => { expect(text).not.toContain('Analytics'); }); + it('starts edge auto-scroll animation only when pointer enters edge zone', () => { + setScopes([StellaOpsScopes.UI_READ]); + const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(1); + const cancelSpy = spyOn(window, 'cancelAnimationFrame'); + const fixture = createComponent(); + const nav = fixture.nativeElement.querySelector('.sidebar__nav') as HTMLElement; + + expect(nav).toBeTruthy(); + expect(rafSpy).not.toHaveBeenCalled(); + + spyOn(nav, 'getBoundingClientRect').and.returnValue(new DOMRect(0, 0, 320, 480)); + nav.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 5 })); + + expect(rafSpy).toHaveBeenCalledTimes(1); + + nav.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + expect(cancelSpy).toHaveBeenCalledWith(1); + }); + function setScopes(scopes: readonly StellaOpsScope[]): void { const baseUser = authService.user(); if (!baseUser) { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index a581bd53a..5d9cbd955 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -23,6 +23,7 @@ import type { ApprovalApi } from '../../core/api/approval.client'; import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component'; import { DoctorTrendService } from '../../core/doctor/doctor-trend.service'; +import { SidebarPreferenceService } from './sidebar-preference.service'; /** * Navigation structure for the shell. @@ -33,6 +34,8 @@ export interface NavSection { label: string; icon: string; route: string; + menuGroupId?: string; + menuGroupLabel?: string; badge$?: () => number | null; sparklineData$?: () => number[]; children?: NavItem[]; @@ -40,19 +43,31 @@ export interface NavSection { requireAnyScope?: readonly StellaOpsScope[]; } +interface DisplayNavSection extends NavSection { + sectionBadge: number | null; + displayChildren: NavItem[]; +} + +interface NavSectionGroup { + id: string; + label: string; + sections: DisplayNavSection[]; +} + /** * AppSidebarComponent - Permanent dark left navigation rail. * - * Design: Always-visible 240px dark sidebar. Never collapses. + * Design: Always-visible 240px dark sidebar, collapsible to 56px icon rail. * Dark charcoal background with amber/gold accents. - * All nav groups are always expanded. Mouse-proximity auto-scroll near edges. + * Collapsible nav groups and foldable sections with smooth CSS grid animation. + * Mouse-proximity auto-scroll near edges. */ @Component({ selector: 'app-sidebar', standalone: true, imports: [ - SidebarNavItemComponent -], + SidebarNavItemComponent, + ], template: ` <aside class="sidebar" @@ -73,65 +88,116 @@ export interface NavSection { </svg> </button> - <!-- Navigation - sections with vertical rail labels --> + <!-- Navigation - collapsible groups with foldable sections --> <nav class="sidebar__nav" #sidebarNav> - @for (section of displaySections(); track section.id; let first = $first) { - @if (!section.displayChildren.length) { - <!-- Root item (no children, e.g. Dashboard) --> - @if (!first) { - <div class="nav-section__divider"></div> + @for (group of displaySectionGroups(); track group.id) { + <div + class="sb-group" + [class.sb-group--collapsed]="!collapsed && sidebarPrefs.collapsedGroups().has(group.id)" + role="group" + [attr.aria-label]="group.label" + > + @if (!collapsed) { + <button + type="button" + class="sb-group__header" + (click)="sidebarPrefs.toggleGroup(group.id)" + [attr.aria-expanded]="!sidebarPrefs.collapsedGroups().has(group.id)" + [attr.aria-controls]="'nav-grp-' + group.id" + > + <span class="sb-group__title">{{ group.label }}</span> + <svg class="sb-group__chevron" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"> + <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> + </svg> + </button> } - <app-sidebar-nav-item - [label]="section.label" - [icon]="section.icon" - [route]="section.route" - [badge]="section.sectionBadge" - [collapsed]="collapsed" - ></app-sidebar-nav-item> - } @else { - <!-- Section group with children --> - <div class="nav-section"> - @if (!first) { - <div class="nav-section__divider"></div> - } - <div class="nav-section__body"> - @if (!collapsed) { - <div class="nav-section__rail" aria-hidden="true"> - <div class="nav-section__rail-line"></div> - <span class="nav-section__rail-label">{{ section.label }}</span> - </div> - } - <app-sidebar-nav-item - [label]="section.label" - [icon]="section.icon" - [route]="section.route" - [badge]="section.sectionBadge" - [collapsed]="collapsed" - ></app-sidebar-nav-item> - @for (child of section.displayChildren; track child.id) { - <app-sidebar-nav-item - [label]="child.label" - [icon]="child.icon" - [route]="child.route" - [badge]="child.badge ?? null" - [isChild]="true" - [collapsed]="collapsed" - ></app-sidebar-nav-item> + <div class="sb-group__body" [id]="'nav-grp-' + group.id"> + <div class="sb-group__body-inner"> + @for (section of group.sections; track section.id; let sectionFirst = $first) { + @if (!collapsed && !sectionFirst) { + <div class="sb-divider"></div> + } + @if (!collapsed && section.displayChildren.length > 0) { + <!-- Section with foldable children --> + <div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)"> + <div class="sb-section__head"> + <app-sidebar-nav-item + [label]="section.label" + [icon]="section.icon" + [route]="section.route" + [badge]="section.sectionBadge" + [collapsed]="collapsed" + ></app-sidebar-nav-item> + <button + type="button" + class="sb-section__fold" + (click)="sidebarPrefs.toggleSection(section.id)" + [attr.aria-expanded]="!sidebarPrefs.collapsedSections().has(section.id)" + [attr.aria-label]="(sidebarPrefs.collapsedSections().has(section.id) ? 'Expand ' : 'Collapse ') + section.label" + > + <svg class="sb-section__fold-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"> + <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> + </svg> + </button> + </div> + <div class="sb-section__body"> + <div class="sb-section__body-inner"> + @for (child of section.displayChildren; track child.id) { + <app-sidebar-nav-item + [label]="child.label" + [icon]="child.icon" + [route]="child.route" + [badge]="child.badge ?? null" + [isChild]="true" + [collapsed]="collapsed" + ></app-sidebar-nav-item> + } + </div> + </div> + </div> + } @else { + <!-- Section without children (or collapsed sidebar) --> + <app-sidebar-nav-item + [label]="section.label" + [icon]="section.icon" + [route]="section.route" + [badge]="section.sectionBadge" + [collapsed]="collapsed" + ></app-sidebar-nav-item> + } } </div> </div> - } + </div> } </nav> <!-- Footer --> <div class="sidebar__footer"> + <button + type="button" + class="sidebar__collapse-btn" + (click)="collapseToggle.emit()" + [attr.title]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'" + [attr.aria-label]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'" + > + <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> + @if (collapsed) { + <polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> + } @else { + <polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> + } + </svg> + </button> <div class="sidebar__footer-divider"></div> <span class="sidebar__version">Stella Ops v1.0.0-alpha</span> </div> </aside> `, styles: [` + /* ================================================================ + Sidebar shell + ================================================================ */ .sidebar { display: flex; flex-direction: column; @@ -149,7 +215,6 @@ export interface NavSection { width: 56px; } - /* Subtle inner glow along right edge */ .sidebar::after { content: ''; position: absolute; @@ -160,7 +225,17 @@ export interface NavSection { background: var(--color-sidebar-border); } - /* ---- Mobile close ---- */ + /* Mobile: always full width regardless of collapsed state */ + @media (max-width: 991px) { + .sidebar, + .sidebar.sidebar--collapsed { + width: 280px; + } + } + + /* ================================================================ + Mobile close + ================================================================ */ .sidebar__close { display: none; position: absolute; @@ -189,12 +264,14 @@ export interface NavSection { } } - /* ---- Nav ---- */ + /* ================================================================ + Scrollable nav area + ================================================================ */ .sidebar__nav { flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 0.5rem 0.5rem; + padding: 0.25rem 0.375rem; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.08) transparent; @@ -210,71 +287,212 @@ export interface NavSection { } } - /* ---- Collapsed nav ---- */ .sidebar--collapsed .sidebar__nav { - padding: 0.5rem 0.25rem; + padding: 0.25rem 0.125rem; } - /* ---- Section groups ---- */ - .nav-section__divider { + /* ================================================================ + Nav group (collapsible section cluster) + ================================================================ */ + .sb-group { + &:not(:first-child) { + margin-top: 0.125rem; + padding-top: 0.25rem; + border-top: 1px solid var(--color-sidebar-divider); + } + } + + /* ---- Group header (toggle button) ---- */ + .sb-group__header { + display: flex; + align-items: center; + width: calc(100% - 0.25rem); + margin: 0 0.125rem 0.125rem; + padding: 0.3125rem 0.5rem; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-sidebar-text-muted); + cursor: pointer; + font-family: inherit; + font-size: 0; + transition: color 0.15s, background 0.15s; + + &:hover { + color: var(--color-sidebar-text-heading); + background: rgba(255, 255, 255, 0.03); + } + + &:focus-visible { + outline: 1px solid var(--color-sidebar-active-border); + outline-offset: -1px; + } + } + + .sb-group__title { + flex: 1; + font-size: 0.5625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + line-height: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* ---- Group chevron (rotates on collapse) ---- */ + .sb-group__chevron { + flex-shrink: 0; + opacity: 0.35; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.15s; + } + + .sb-group--collapsed .sb-group__chevron { + transform: rotate(-90deg); + } + + .sb-group__header:hover .sb-group__chevron { + opacity: 0.7; + } + + /* ---- Animated group body (CSS grid trick) ---- */ + .sb-group__body { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sb-group--collapsed .sb-group__body { + grid-template-rows: 0fr; + } + + .sb-group__body-inner { + overflow: hidden; + } + + /* ================================================================ + Sections within a group (foldable children) + ================================================================ */ + .sb-divider { height: 1px; background: var(--color-sidebar-divider); - margin: 0.5rem 0.75rem; + margin: 0.25rem 0.75rem; } - .nav-section__body { + /* Section head: nav-item + fold toggle */ + .sb-section__head { position: relative; } - .nav-section__rail { + .sb-section__fold { position: absolute; - left: 2px; - top: 0; - bottom: 0; - width: 14px; + right: 0.375rem; + top: 50%; + transform: translateY(-50%); display: flex; align-items: center; justify-content: center; - pointer-events: none; - z-index: 1; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-sidebar-text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; + z-index: 2; + + &:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--color-sidebar-text-heading); + } + + &:focus-visible { + opacity: 1; + outline: 1px solid var(--color-sidebar-active-border); + } } - .nav-section__rail-line { - position: absolute; - top: 6px; - bottom: 6px; - left: 50%; - width: 1px; - background: rgba(245, 200, 120, 0.12); - transform: translateX(-50%); + /* Show fold button on hover or when section is folded */ + .sb-section__head:hover .sb-section__fold, + .sb-section--folded .sb-section__fold { + opacity: 1; } - .nav-section__rail-label { - position: relative; - z-index: 1; - writing-mode: vertical-rl; - transform: rotate(180deg); - font-size: 0.5rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--color-sidebar-group-text); - white-space: nowrap; - background: var(--color-sidebar-bg); - padding: 4px 0; - line-height: 1; + .sb-section__fold-icon { + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); } - /* ---- Footer ---- */ + .sb-section--folded .sb-section__fold-icon { + transform: rotate(-90deg); + } + + /* Animated section body */ + .sb-section__body { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.22s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sb-section--folded .sb-section__body { + grid-template-rows: 0fr; + } + + .sb-section__body-inner { + overflow: hidden; + margin-left: 1.25rem; + border-left: 1px solid rgba(245, 166, 35, 0.12); + } + + /* ================================================================ + Footer with collapse toggle + ================================================================ */ .sidebar__footer { flex-shrink: 0; - padding: 0.5rem 0.75rem; + padding: 0.375rem 0.75rem 0.5rem; + } + + .sidebar__collapse-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 28px; + border: 1px solid var(--color-sidebar-divider); + border-radius: 6px; + background: transparent; + color: var(--color-sidebar-text-muted); + cursor: pointer; + margin-bottom: 0.375rem; + transition: color 0.15s, background 0.15s, border-color 0.15s; + + &:hover { + color: var(--color-sidebar-text-heading); + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.12); + } + + &:focus-visible { + outline: 1px solid var(--color-sidebar-active-border); + outline-offset: -1px; + } + } + + /* Hide collapse button on mobile (mobile uses the close X) */ + @media (max-width: 991px) { + .sidebar__collapse-btn { + display: none; + } } .sidebar__footer-divider { height: 1px; background: var(--color-sidebar-divider); - margin-bottom: 0.5rem; + margin-bottom: 0.375rem; } .sidebar__version { @@ -289,7 +507,7 @@ export interface NavSection { } .sidebar--collapsed .sidebar__footer { - padding: 0.5rem; + padding: 0.375rem 0.25rem 0.5rem; } .sidebar--collapsed .sidebar__version { @@ -307,6 +525,8 @@ export class AppSidebarComponent implements AfterViewInit { private readonly doctorTrendService = inject(DoctorTrendService); private readonly ngZone = inject(NgZone); + readonly sidebarPrefs = inject(SidebarPreferenceService); + @Input() collapsed = false; @Output() mobileClose = new EventEmitter<void>(); @Output() collapseToggle = new EventEmitter<void>(); @@ -324,6 +544,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Dashboard', icon: 'dashboard', route: '/mission-control/board', + menuGroupId: 'release-control', + menuGroupLabel: 'Release Control', requireAnyScope: [ StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ, @@ -335,6 +557,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Releases', icon: 'package', route: '/releases/deployments', + menuGroupId: 'release-control', + menuGroupLabel: 'Release Control', requireAnyScope: [ StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE, @@ -384,6 +608,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Vulnerabilities', icon: 'shield', route: '/security', + menuGroupId: 'security-evidence', + menuGroupLabel: 'Security & Evidence', sparklineData$: () => this.doctorTrendService.securityTrend(), requireAnyScope: [ StellaOpsScopes.SCANNER_READ, @@ -406,6 +632,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Evidence', icon: 'file-text', route: '/evidence/overview', + menuGroupId: 'security-evidence', + menuGroupLabel: 'Security & Evidence', requireAnyScope: [ StellaOpsScopes.RELEASE_READ, StellaOpsScopes.POLICY_AUDIT, @@ -426,6 +654,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Operations', icon: 'settings', route: '/ops/operations', + menuGroupId: 'platform-setup', + menuGroupLabel: 'Platform & Setup', sparklineData$: () => this.doctorTrendService.platformTrend(), requireAnyScope: [ StellaOpsScopes.UI_ADMIN, @@ -445,6 +675,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Setup', icon: 'server', route: '/setup/system', + menuGroupId: 'platform-setup', + menuGroupLabel: 'Platform & Setup', requireAnyScope: [ StellaOpsScopes.UI_ADMIN, StellaOpsScopes.RELEASE_READ, @@ -469,14 +701,42 @@ export class AppSidebarComponent implements AfterViewInit { }); /** Sections with duplicate children removed and badges resolved */ - readonly displaySections = computed(() => { - return this.visibleSections().map(section => ({ + readonly displaySections = computed<DisplayNavSection[]>(() => { + return this.visibleSections().map((section) => ({ ...section, sectionBadge: section.badge$?.() ?? null, - displayChildren: (section.children ?? []).filter(child => child.route !== section.route), + displayChildren: (section.children ?? []).filter((child) => child.route !== section.route), })); }); + /** Menu groups rendered in deterministic order for scanability */ + readonly displaySectionGroups = computed<NavSectionGroup[]>(() => { + const orderedGroups = new Map<string, NavSectionGroup>(); + const groupOrder = ['release-control', 'security-evidence', 'platform-setup', 'misc']; + + for (const groupId of groupOrder) { + orderedGroups.set(groupId, { + id: groupId, + label: this.resolveMenuGroupLabel(groupId), + sections: [], + }); + } + + for (const section of this.displaySections()) { + const groupId = section.menuGroupId ?? 'misc'; + const group = orderedGroups.get(groupId) ?? { + id: groupId, + label: section.menuGroupLabel ?? this.resolveMenuGroupLabel(groupId), + sections: [], + }; + + group.sections.push(section); + orderedGroups.set(groupId, group); + } + + return Array.from(orderedGroups.values()).filter((group) => group.sections.length > 0); + }); + constructor() { this.loadPendingApprovalsBadge(); this.router.events @@ -510,10 +770,6 @@ export class AppSidebarComponent implements AfterViewInit { .map((child) => this.filterItem(child)) .filter((child): child is NavItem => child !== null); - if (visibleChildren.length === 0) { - return null; - } - const children = visibleChildren.map((child) => this.withDynamicChildState(child)); return { @@ -522,6 +778,23 @@ export class AppSidebarComponent implements AfterViewInit { }; } + private resolveMenuGroupLabel(groupId: string): string { + switch (groupId) { + case 'release-control': + return 'Release Control'; + case 'security-evidence': + return 'Security & Evidence'; + case 'platform-setup': + return 'Platform & Setup'; + default: + return 'Global Menu'; + } + } + + groupRoute(group: NavSectionGroup): string { + return group.sections[0]?.route ?? '/mission-control/board'; + } + private withDynamicChildState(item: NavItem): NavItem { if (item.id !== 'rel-approvals') { return item; @@ -579,14 +852,34 @@ export class AppSidebarComponent implements AfterViewInit { const EDGE_ZONE = 60; const MAX_SPEED = 8; let scrollDirection = 0; // -1 = up, 0 = none, 1 = down - let animFrameId = 0; + let animFrameId: number | null = null; let paused = false; - const scrollLoop = () => { - if (scrollDirection !== 0 && !paused) { - navEl.scrollTop += scrollDirection; + const stopScrollLoop = () => { + if (animFrameId === null) { + return; } - animFrameId = requestAnimationFrame(scrollLoop); + + cancelAnimationFrame(animFrameId); + animFrameId = null; + }; + + const runScrollLoop = () => { + if (animFrameId !== null) { + return; + } + + const tick = () => { + if (scrollDirection === 0 || paused) { + animFrameId = null; + return; + } + + navEl.scrollTop += scrollDirection; + animFrameId = requestAnimationFrame(tick); + }; + + animFrameId = requestAnimationFrame(tick); }; const onMouseMove = (e: MouseEvent) => { @@ -595,7 +888,7 @@ export class AppSidebarComponent implements AfterViewInit { // Check if hovering a nav item (pause scrolling) const target = e.target as HTMLElement; - paused = !!(target.closest('.nav-item') || target.closest('.nav-group__header')); + paused = !!(target.closest('.nav-item') || target.closest('.sb-group__header')); const distFromTop = mouseY - rect.top; const distFromBottom = rect.bottom - mouseY; @@ -609,22 +902,28 @@ export class AppSidebarComponent implements AfterViewInit { } else { scrollDirection = 0; } + + if (scrollDirection !== 0 && !paused) { + runScrollLoop(); + } else { + stopScrollLoop(); + } }; const onMouseLeave = () => { scrollDirection = 0; paused = false; + stopScrollLoop(); }; // Run outside Angular zone to avoid unnecessary change detection this.ngZone.runOutsideAngular(() => { navEl.addEventListener('mousemove', onMouseMove); navEl.addEventListener('mouseleave', onMouseLeave); - animFrameId = requestAnimationFrame(scrollLoop); }); this.destroyRef.onDestroy(() => { - cancelAnimationFrame(animFrameId); + stopScrollLoop(); navEl.removeEventListener('mousemove', onMouseMove); navEl.removeEventListener('mouseleave', onMouseLeave); }); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts new file mode 100644 index 000000000..2d6fe9863 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts @@ -0,0 +1,82 @@ +import { Injectable, signal, computed, effect } from '@angular/core'; + +export interface SidebarPreferences { + sidebarCollapsed: boolean; + collapsedGroups: string[]; + collapsedSections: string[]; +} + +const STORAGE_KEY = 'stellaops.sidebar.preferences'; + +const DEFAULTS: SidebarPreferences = { + sidebarCollapsed: false, + collapsedGroups: [], + collapsedSections: ['ops', 'setup'], +}; + +@Injectable({ providedIn: 'root' }) +export class SidebarPreferenceService { + private readonly _state = signal<SidebarPreferences>(this.load()); + + readonly sidebarCollapsed = computed(() => this._state().sidebarCollapsed); + readonly collapsedGroups = computed(() => new Set(this._state().collapsedGroups)); + readonly collapsedSections = computed(() => new Set(this._state().collapsedSections)); + + constructor() { + effect(() => this.save(this._state())); + } + + toggleSidebar(): void { + this._state.update((s) => ({ ...s, sidebarCollapsed: !s.sidebarCollapsed })); + } + + toggleGroup(groupId: string): void { + this._state.update((s) => { + const set = new Set(s.collapsedGroups); + if (set.has(groupId)) set.delete(groupId); + else set.add(groupId); + return { ...s, collapsedGroups: [...set] }; + }); + } + + toggleSection(sectionId: string): void { + this._state.update((s) => { + const set = new Set(s.collapsedSections); + if (set.has(sectionId)) set.delete(sectionId); + else set.add(sectionId); + return { ...s, collapsedSections: [...set] }; + }); + } + + private load(): SidebarPreferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + sidebarCollapsed: + typeof parsed.sidebarCollapsed === 'boolean' + ? parsed.sidebarCollapsed + : DEFAULTS.sidebarCollapsed, + collapsedGroups: Array.isArray(parsed.collapsedGroups) + ? parsed.collapsedGroups + : DEFAULTS.collapsedGroups, + collapsedSections: Array.isArray(parsed.collapsedSections) + ? parsed.collapsedSections + : DEFAULTS.collapsedSections, + }; + } + } catch { + /* ignore parse errors */ + } + return { ...DEFAULTS }; + } + + private save(state: SidebarPreferences): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + /* ignore quota / private-mode errors */ + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index f2144836b..7f8b2721a 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -78,13 +78,28 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; <a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a> } + <button + type="button" + class="topbar__context-toggle" + [class.topbar__context-toggle--active]="mobileContextOpen()" + [attr.aria-expanded]="mobileContextOpen()" + aria-controls="topbar-context-row" + (click)="toggleMobileContext()" + > + Context + </button> + <!-- User menu --> <app-user-menu></app-user-menu> </div> </div> <!-- Row 2: Tenant (if multi) + Context chips + Locale --> - <div class="topbar__row topbar__row--secondary"> + <div + id="topbar-context-row" + class="topbar__row topbar__row--secondary" + [class.topbar__row--secondary-open]="mobileContextOpen()" + > <!-- Tenant selector (only shown when multiple tenants) --> @if (showTenantSelector()) { <div class="topbar__tenant"> @@ -198,6 +213,14 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; .topbar__row--secondary { padding: 0 0.5rem; + display: none; + } + + .topbar__row--secondary-open { + display: flex; + height: auto; + padding: 0.35rem 0.5rem; + overflow: visible; } } @@ -312,6 +335,34 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; border-color: var(--color-brand-primary); } + .topbar__context-toggle { + display: none; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + height: 28px; + padding: 0 0.45rem; + background: var(--color-surface-primary); + color: var(--color-text-secondary); + font-size: 0.625rem; + font-family: var(--font-family-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; + } + + .topbar__context-toggle:hover { + border-color: var(--color-brand-primary); + color: var(--color-text-primary); + background: var(--color-surface-secondary); + } + + .topbar__context-toggle--active { + border-color: var(--color-brand-primary); + background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary)); + color: var(--color-brand-primary); + } + @media (max-width: 575px) { .topbar__right { gap: 0.25rem; @@ -320,6 +371,12 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; .topbar__primary-action { display: none; } + + .topbar__context-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + } } /* ---- Row 2 separator ---- */ @@ -495,6 +552,7 @@ export class AppTopbarComponent { ); readonly localePreferenceSyncAttempted = signal(false); readonly scopePanelOpen = signal(false); + readonly mobileContextOpen = signal(false); readonly tenantPanelOpen = signal(false); readonly tenantSwitchInFlight = signal(false); readonly tenantBootstrapAttempted = signal(false); @@ -551,6 +609,10 @@ export class AppTopbarComponent { this.scopePanelOpen.update((open) => !open); } + toggleMobileContext(): void { + this.mobileContextOpen.update((open) => !open); + } + closeScopePanel(): void { this.scopePanelOpen.set(false); } @@ -654,6 +716,7 @@ export class AppTopbarComponent { onEscape(): void { this.closeScopePanel(); this.closeTenantPanel(); + this.mobileContextOpen.set(false); } @HostListener('document:click', ['$event']) @@ -666,12 +729,17 @@ export class AppTopbarComponent { const host = this.elementRef.nativeElement; const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false; const insideTenant = host.querySelector('.topbar__tenant')?.contains(target) ?? false; + const insideContextToggle = host.querySelector('.topbar__context-toggle')?.contains(target) ?? false; + const insideContextRow = host.querySelector('.topbar__row--secondary')?.contains(target) ?? false; if (!insideScope) { this.closeScopePanel(); } if (!insideTenant) { this.closeTenantPanel(); } + if (!insideContextToggle && !insideContextRow) { + this.mobileContextOpen.set(false); + } } private async loadTenantContextIfNeeded(): Promise<void> { diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts index 9fe51b3d7..986c632ce 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/context-chips.component.ts @@ -381,6 +381,40 @@ interface DropdownOption { font-size: 0.625rem; color: var(--color-status-error-text); } + + @media (max-width: 575px) { + .ctx { + width: 100%; + align-items: flex-start; + } + + .ctx__controls { + flex-wrap: wrap; + gap: 0.2rem; + } + + .ctx__dropdown-btn { + height: 22px; + padding: 0 0.35rem; + } + + .ctx__dropdown-key { + display: none; + } + + .ctx__dropdown-val { + max-width: 82px; + font-size: 0.58rem; + } + + .ctx__sep { + display: none; + } + + .ctx__chips { + display: none; + } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/Web/StellaOps.Web/src/i18n/bg-BG.common.json b/src/Web/StellaOps.Web/src/i18n/bg-BG.common.json index 68fb71f24..d9bbc0b86 100644 --- a/src/Web/StellaOps.Web/src/i18n/bg-BG.common.json +++ b/src/Web/StellaOps.Web/src/i18n/bg-BG.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "bg-BG", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "пакет \"Стелла\"", "common.error.generic": "Нещо се обърка.", "common.error.not_found": "Заявеният ресурс не беше намерен.", "common.error.unauthorized": "Нямате права да извършите това действие.", diff --git a/src/Web/StellaOps.Web/src/i18n/de-DE.common.json b/src/Web/StellaOps.Web/src/i18n/de-DE.common.json index 90e5fd9c7..5d7ea2535 100644 --- a/src/Web/StellaOps.Web/src/i18n/de-DE.common.json +++ b/src/Web/StellaOps.Web/src/i18n/de-DE.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "de-DE", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "Etwas ist schiefgelaufen.", "common.error.not_found": "Die angeforderte Ressource wurde nicht gefunden.", "common.error.unauthorized": "Sie haben keine Berechtigung, diese Aktion auszuführen.", diff --git a/src/Web/StellaOps.Web/src/i18n/en-US.common.json b/src/Web/StellaOps.Web/src/i18n/en-US.common.json index d83de12a1..5260b0ca6 100644 --- a/src/Web/StellaOps.Web/src/i18n/en-US.common.json +++ b/src/Web/StellaOps.Web/src/i18n/en-US.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "en-US", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "Something went wrong.", "common.error.not_found": "The requested resource was not found.", "common.error.unauthorized": "You do not have permission to perform this action.", diff --git a/src/Web/StellaOps.Web/src/i18n/es-ES.common.json b/src/Web/StellaOps.Web/src/i18n/es-ES.common.json index a302cde35..0a11b38c8 100644 --- a/src/Web/StellaOps.Web/src/i18n/es-ES.common.json +++ b/src/Web/StellaOps.Web/src/i18n/es-ES.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "es-ES", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "Algo salió mal.", "common.error.not_found": "No se encontró el recurso solicitado.", "common.error.unauthorized": "No tienes permiso para realizar esta acción.", diff --git a/src/Web/StellaOps.Web/src/i18n/fr-FR.common.json b/src/Web/StellaOps.Web/src/i18n/fr-FR.common.json index 2a703129b..16507f955 100644 --- a/src/Web/StellaOps.Web/src/i18n/fr-FR.common.json +++ b/src/Web/StellaOps.Web/src/i18n/fr-FR.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "fr-FR", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "Quelque chose s'est mal passé.", "common.error.not_found": "La ressource demandée n'a pas été trouvée.", "common.error.unauthorized": "Vous n'êtes pas autorisé à effectuer cette action.", diff --git a/src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json b/src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json index 6826ce905..ed5a66ec8 100644 --- a/src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json +++ b/src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json @@ -3,7 +3,7 @@ "_meta": { "version": "1.0", "locale": "en-US", - "description": "Micro-interaction copy for StellaOps Console (MI9)" + "description": "Micro-interaction copy for Stella Ops Console (MI9)" }, "loading": { "skeleton": "Loading...", diff --git a/src/Web/StellaOps.Web/src/i18n/ru-RU.common.json b/src/Web/StellaOps.Web/src/i18n/ru-RU.common.json index 363674969..bb81e4339 100644 --- a/src/Web/StellaOps.Web/src/i18n/ru-RU.common.json +++ b/src/Web/StellaOps.Web/src/i18n/ru-RU.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "ru-RU", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "пакет \"Стелла\"", "common.error.generic": "Что-то пошло не так.", "common.error.not_found": "Запрошенный ресурс не найден.", "common.error.unauthorized": "У вас нет разрешения на выполнение этого действия.", diff --git a/src/Web/StellaOps.Web/src/i18n/uk-UA.common.json b/src/Web/StellaOps.Web/src/i18n/uk-UA.common.json index 01873a285..6be7dcf5a 100644 --- a/src/Web/StellaOps.Web/src/i18n/uk-UA.common.json +++ b/src/Web/StellaOps.Web/src/i18n/uk-UA.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "uk-UA", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "пакет \"Стелла\"", "common.error.generic": "Щось пішло не так.", "common.error.not_found": "Потрібний ресурс не знайдено.", "common.error.unauthorized": "Ви не маєте дозволу на виконання цієї дії.", diff --git a/src/Web/StellaOps.Web/src/i18n/zh-CN.common.json b/src/Web/StellaOps.Web/src/i18n/zh-CN.common.json index fc9b4fe63..cfd4fa119 100644 --- a/src/Web/StellaOps.Web/src/i18n/zh-CN.common.json +++ b/src/Web/StellaOps.Web/src/i18n/zh-CN.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "zh-CN", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "出了点问题。", "common.error.not_found": "未找到请求的资源。", "common.error.unauthorized": "您没有执行此操作的权限。", diff --git a/src/Web/StellaOps.Web/src/i18n/zh-TW.common.json b/src/Web/StellaOps.Web/src/i18n/zh-TW.common.json index 16261009b..d1172288a 100644 --- a/src/Web/StellaOps.Web/src/i18n/zh-TW.common.json +++ b/src/Web/StellaOps.Web/src/i18n/zh-TW.common.json @@ -1,8 +1,9 @@ { "_meta": { "locale": "zh-TW", - "description": "Offline fallback bundle for StellaOps Console" + "description": "Offline fallback bundle for Stella Ops Console" }, + "ui.branding.app_name": "Stella Ops", "common.error.generic": "出了點問題。", "common.error.not_found": "未找到請求的資源。", "common.error.unauthorized": "您沒有執行此操作的權限。", diff --git a/src/Web/StellaOps.Web/src/manifest.webmanifest b/src/Web/StellaOps.Web/src/manifest.webmanifest index 9e7c7b587..1ecb001c7 100644 --- a/src/Web/StellaOps.Web/src/manifest.webmanifest +++ b/src/Web/StellaOps.Web/src/manifest.webmanifest @@ -1,6 +1,6 @@ { "name": "Stella Ops", - "short_name": "StellaOps", + "short_name": "Stella Ops", "description": "Release control plane for container estates", "start_url": "/", "display": "standalone", diff --git a/src/Web/StellaOps.Web/src/styles/tokens/_colors.scss b/src/Web/StellaOps.Web/src/styles/tokens/_colors.scss index 79cdb62b1..aa2f8c3b7 100644 --- a/src/Web/StellaOps.Web/src/styles/tokens/_colors.scss +++ b/src/Web/StellaOps.Web/src/styles/tokens/_colors.scss @@ -30,8 +30,8 @@ // --------------------------------------------------------------------------- --color-text-primary: #3D2E0A; --color-text-heading: #1C1200; - --color-text-secondary: #6B5A2E; - --color-text-muted: #9A8F78; + --color-text-secondary: #5A4A24; + --color-text-muted: #756949; --color-text-inverse: #F5F0E6; --color-text-link: #D4920A; --color-text-link-hover: #F5A623; @@ -39,8 +39,8 @@ // --------------------------------------------------------------------------- // Border Colors // --------------------------------------------------------------------------- - --color-border-primary: rgba(212, 201, 168, 0.3); - --color-border-secondary: rgba(212, 201, 168, 0.5); + --color-border-primary: rgba(176, 160, 118, 0.52); + --color-border-secondary: rgba(176, 160, 118, 0.72); --color-border-focus: #F5A623; --color-border-error: #ef4444; diff --git a/src/Web/StellaOps.Web/src/tests/doctor/doctor-wizard-mapping.spec.ts b/src/Web/StellaOps.Web/src/tests/doctor/doctor-wizard-mapping.spec.ts index cf3d8869d..24ad5d8a8 100644 --- a/src/Web/StellaOps.Web/src/tests/doctor/doctor-wizard-mapping.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/doctor/doctor-wizard-mapping.spec.ts @@ -92,15 +92,15 @@ describe('DoctorWizardMapping', () => { describe('buildWizardDeepLink', () => { it('should build correct deep link for database', () => { - expect(buildWizardDeepLink('database')).toBe('/setup/wizard?step=database&mode=reconfigure'); + expect(buildWizardDeepLink('database')).toBe('/setup-wizard/wizard?step=database&mode=reconfigure'); }); it('should build correct deep link for authority', () => { - expect(buildWizardDeepLink('authority')).toBe('/setup/wizard?step=authority&mode=reconfigure'); + expect(buildWizardDeepLink('authority')).toBe('/setup-wizard/wizard?step=authority&mode=reconfigure'); }); it('should build correct deep link for telemetry', () => { - expect(buildWizardDeepLink('telemetry')).toBe('/setup/wizard?step=telemetry&mode=reconfigure'); + expect(buildWizardDeepLink('telemetry')).toBe('/setup-wizard/wizard?step=telemetry&mode=reconfigure'); }); }); }); diff --git a/src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts b/src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts index 57bcdb777..b88b8bfca 100644 --- a/src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts @@ -172,13 +172,14 @@ test.describe('Nav shell canonical domains', () => { const navText = (await page.locator('aside.sidebar').textContent()) ?? ''; const labels = [ - 'Mission Board', - 'Mission Alerts', - 'Mission Activity', + 'Release Control', + 'Security & Evidence', + 'Platform & Setup', + 'Dashboard', 'Releases', - 'Security', + 'Vulnerabilities', 'Evidence', - 'Ops', + 'Operations', 'Setup', ]; @@ -198,6 +199,45 @@ test.describe('Nav shell canonical domains', () => { expect(navText).not.toContain('Administration'); expect(navText).not.toContain('Policy Studio'); }); + + test('group headers are unique and navigate to group landing routes', async ({ page }) => { + await go(page, '/mission-control/board'); + await ensureShell(page); + + const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' }); + const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' }); + const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' }); + + await expect(releaseGroup).toHaveCount(1); + await expect(securityGroup).toHaveCount(1); + await expect(platformGroup).toHaveCount(1); + + await releaseGroup.click(); + await expect(page).toHaveURL(/\/mission-control\/board$/); + + await securityGroup.click(); + await expect(page).toHaveURL(/\/security(\/|$)/); + + await platformGroup.click(); + await expect(page).toHaveURL(/\/ops(\/|$)/); + }); + + test('grouped root entries navigate when clicked', async ({ page }) => { + await go(page, '/mission-control/board'); + await ensureShell(page); + + await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click(); + await expect(page).toHaveURL(/\/releases\/deployments$/); + + await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click(); + await expect(page).toHaveURL(/\/security(\/|$)/); + + await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click(); + await expect(page).toHaveURL(/\/evidence\/overview$/); + + await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click(); + await expect(page).toHaveURL(/\/ops\/operations$/); + }); }); test.describe('Nav shell breadcrumbs and stability', () => { diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts index 08c1f0702..05e099daf 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts @@ -21,7 +21,7 @@ export const mockConfig = { redirectUri: 'http://127.0.0.1:4400/auth/callback', postLogoutRedirectUri: 'http://127.0.0.1:4400/', scope: - 'openid profile email ui.read advisory:search advisory:read search:read findings:read vex:read policy:read health:read', + 'openid profile email ui.read advisory:read advisory-ai:view advisory-ai:operate findings:read vex:read policy:read health:read', audience: 'https://scanner.local', dpopAlgorithms: ['ES256'], refreshLeewaySeconds: 60, @@ -55,9 +55,9 @@ export const shellSession = { ...policyAuthorSession.scopes, 'ui.read', 'admin', - 'advisory:search', 'advisory:read', - 'search:read', + 'advisory-ai:view', + 'advisory-ai:operate', 'findings:read', 'vex:read', 'policy:read',