component_architecture_web.md - Stella Ops Web (2025Q4)

Angular 21 frontend SPA for StellaOps console.

Scope. Frontend architecture for Web: the Angular 21 single-page application providing the StellaOps console interface.

Note: For the comprehensive Web UI architecture including all features, see ../ui/architecture.md. This file provides a condensed overview.


0) Mission & boundaries

Mission. Provide an intuitive, responsive web interface for StellaOps operators to manage scans, review findings, configure policies, and monitor system health.

Boundaries.


1) Solution & project layout

src/Web/StellaOps.Web/
 ├─ src/
 │   ├─ app/
 │   │   ├─ core/                    # Cross-cutting singletons
 │   │   │   ├─ api/                 # Typed HTTP clients (one per backend surface)
 │   │   │   ├─ auth/                # OIDC/PKCE/DPoP, session store, guards, interceptors
 │   │   │   ├─ config/              # Runtime AppConfigService + config/backend-reachable guards
 │   │   │   ├─ context/             # Global tenant/context interceptor + param map
 │   │   │   ├─ i18n/                # Locale catalog, translate pipe, date formatting
 │   │   │   ├─ navigation/          # Page-title strategy, breadcrumbs
 │   │   │   ├─ orchestrator/        # Operator-metadata interceptor
 │   │   │   ├─ analytics/ aoc/ branding/ console/ doctor/ guards/ http/
 │   │   │   ├─ models/ policy/ services/ telemetry/ testing/
 │   │   ├─ shared/                  # Reusable UI building blocks
 │   │   │   ├─ components/ directives/ pipes/ services/ ui/ utils/
 │   │   │   ├─ accessibility/ domain/ overlays/
 │   │   ├─ layout/                  # App shell, sidebar, topbar, breadcrumb, global search
 │   │   ├─ routes/                  # Top-level lazy route aggregators (ops, security, releases, …)
 │   │   ├─ features/                # 100+ feature areas (dashboard-v3, security, triage,
 │   │   │                           #   evidence, compliance, inventory, policy, graph, …)
 │   │   ├─ app.config.ts            # ApplicationConfig: router, HTTP, interceptors, DI providers
 │   │   └─ app.routes.ts
 │   ├─ assets/
 │   ├─ environments/                # environment.ts / environment.prod.ts (build-time flags)
 │   ├─ styles/
 │   └─ tests/                       # Vitest spec batches grouped by feature/sprint
 ├─ e2e/                             # Playwright end-to-end specs
 ├─ scripts/                         # Live/e2e harness + guard scripts
 ├─ angular.json
 ├─ package.json
 └─ tsconfig.json

The features/ tree is far larger than a handful of modules — there are 100+ feature directories. Routing is composed through lazy routes/*.routes.ts aggregators that import feature components on demand (see §3).


2) Technology stack


3) Key features

Top-level routes are declared in src/app/app.routes.ts. Each non-trivial branch is gated by the standard canMatch chain [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, <scope guard>] and redirects to /console/profile (Insufficient Permissions) when the scope guard fails.

RouteTitleScope guard (any-of unless noted)
'' (root)Dashboardauth only — lazy-loads DashboardV3Component
mission-controlMission Controlui.read, release:read, scanner:read, sbom:read
environmentsEnvironmentsops guard (ui.admin, orch:read, orch:operate, ops.health, notify.viewer, policy:*) — plus merged sub-surfaces, see note
releasesReleasesrelease:read, release:write, release:publish, orch:read, orch:operate, policy:read (NAV_ACCESS.promotions)
developerDeveloper Servicesall-of release:read, release:write, scanner:read, sbom:read, findings:read
securitySecurityscanner:read, sbom:read, advisory:read, vex:read, exceptions:read, findings:read, vuln:view
triageVulnerabilitiesauth only
evidenceEvidenceevidence:read, release:read, policy:audit, authority:audit.read, signer:read, attest:read, vex:read — plus merged sub-surfaces, see note
assurance / complianceAssurancepolicy:read, policy:audit, notify.viewer, findings:read, ui.admin
inventoryAsset Inventorygraph:read
opsOperationsops guard (as environments)
setupSetupui.admin, orch:read, orch:operate, release:read, trust:read, attest:read, attest:create
settingsSettingsauth only
console-adminConsole Adminui.admin
notificationsNotificationsauth only
docs / welcomeDocumentation / Missionunguarded
auth/callback, auth/silent-refreshOIDC redirect endpoints (unguarded)
logged-outSigned Outpost-sign-out confirmation page (unguarded)

The table is illustrative — route-access.ts is the contract (corrected 2026-07-12; the previous version cited two scopes that do not exist). Three traps:

  • There is no health:read scope. The ops guard uses ops.health(scopes.ts:163 OPS_HEALTH; canonical StellaOpsScopes.cs:865 OpsHealth). health:read appears nowhere in src/.
  • There is no vex:export scope. The evidence guard uses vex:read. vex:export is not defined in scopes.ts and is not in the canonical catalog.
  • operations and the evidence family are mergeAny composites, not flat any-of lists: they union the scopes shown plus evidenceAudit, feedsAirgap, telemetryParticipation and federationInstallation (route-access.ts:124-141). The real accepted-scope set is wider than any table can usefully show — read NAV_ACCESS / ROUTE_FAMILY_ACCESS.

The Setup family is only a coarse entry gate; each sensitive child has an exact owner-aligned contract. Identity Providers requires both ui.admin and authority:idp.read; mutations additionally require authority:idp.write and fresh authentication. Certificates & Trust deliberately joins two services without conflating their permissions: Platform trust inventory pages require trust:read (with trust:write for create/register/rotate and trust:admin for revoke/block actions), while Attestor Transparency requires attest:read or attest:create to view and exactly attest:create to mutate. Navigation, parent tabs, and child canMatch guards use the same rules from route-access.ts.

A large block of legacy paths (administration/*, admin/*, platform/*, release-control/*, evidence-audit/*, security-risk/*, jobengine/*, …) is preserved as redirectTo entries (via LEGACY_REDIRECT_ROUTES and inline children) so older deep links keep working after the route taxonomy reorganisation. The wildcard ** route renders NotFoundComponent.

Environment read and operate boundary

The environment estate is readable independently of environment mutation. The readiness page and /releases/environments list/detail remain available to the applicable read personas, but create, delete, settings, target health/config, freeze-window, and protection-class actions require the exact orch:operate claim enforced by Release Orchestrator. The :environmentId/settings deep link is guarded and read-only sessions are returned to the environment list; a ?tab=settings query is normalized to Overview. Read-only detail keeps Targets, Freeze Windows, and Protection visible as inspection surfaces without rendering or executing their mutation handlers.

Estate-list enrichment is intentionally bounded in ReleaseEnvironmentHttpClient: at most two environments (four target/freeze-window reads) are enriched at once, and the backend list order is restored before the result is emitted. This keeps a large dashboard estate from consuming the shared PostgreSQL client budget while preserving the existing response contract; an unknown environment still follows the owning API’s 404 environment_not_found behavior.

Release authoring and promotion boundary

Release discovery and history remain readable under the applicable release or orchestrator read scopes. The Console’s release/version/hotfix authoring workflows publish sealed bundle content and therefore require orch:operate before their routes match; a release:write reader is not offered the Genesis authoring checklist and cannot enter /releases/new, /releases/versions/new, the legacy version creator, or /releases/hotfixes/new. Promotion creation is separate and requires release:publish; promotion list/detail reads remain available to the broader promotion-read contract. Route guards repeat the template and handler checks so copied deep links cannot defer authorization failure until submit time.

Dashboard bootstrap action boundary

The empty-estate Dashboard derives both its bootstrap cards and its activation checklist from the current session’s effective scopes. Registry and advisory connection actions require the Setup-owning ui.admin scope plus an integration write/operate grant; topology and release-authoring handoffs require orch:operate; reachable-risk setup requires both a Security read scope and an operative scan/investigate/simulate scope; signing-key setup requires ui.admin plus signer rotate/admin. The synthetic admin scope retains the complete path. Unknown or read-only sessions fail closed: they receive setup-ownership guidance instead of links to routes or actions they cannot complete.

Dashboard bootstrap is also bounded. PlatformContextStore gives the complete regions -> preferences -> environments hydration chain 15 seconds, cancels the active request when that bound expires, and completes into an explicit degraded error state. Reload starts a new generation so late responses from an older attempt cannot overwrite current context. The Dashboard presents its existing custom skeleton through stella-bounded-loading: after the shared 10-second UI bound it exposes Retry, and a transport timeout becomes an honest context error instead of an indefinite full-page shimmer or a fabricated empty estate.

The Promotion Readiness lens has an independent 10-second deadline around its complete dashboard-plus-environment enrichment snapshot. The deadline cancels non-settling target or freeze-window reads as one operation, presents an honest unavailable state with Retry, and uses load generations so a late result cannot overwrite a newer attempt. The bound is not multiplied by environment count.

The Dashboard findings feed is a severity-ranked Priority findings slice, not a recency feed: Findings.Security supports deterministic severity or actionable ordering, while the projection exposes its update time through the compatibility FindingDto.firstSeen field. The card therefore labels row times as updates and renders “showing N of total” from the V2 page envelope instead of calling the fetched page length a tenant-wide count. The V1 summaries route is used only when the V2 route is absent (404); a present-but-unhealthy V2 service must reach the card’s explicit error and Retry state rather than being hidden by legacy data.

3.1 VEX Gate Inline Actions (Sprint 20260208_073)

Quiet-triage promote actions now support inline VEX gating with deterministic evidence tiers:

The UI behavior remains offline-first and deterministic:

3.2 Signals Runtime Dashboard (Sprint 20260208_072) — NOT IMPLEMENTED (removed)

Status (verified against source): The features/signals/ directory no longer exists in src/Web/StellaOps.Web. The component, service, and models files cited below are absent, there is no signals.routes.ts, and app.routes.ts registers no ops/signals route. Only the historical Vitest spec files survive under src/tests/signals_runtime_dashboard/. Treat this section as a record of a retired surface, not current behaviour.

Signals runtime operations once included a dedicated dashboard route:

Former dashboard behavior:

Verification coverage:

3.3 Audit Trail Reason Capsule (Sprint 20260208_067)

Findings and triage views now expose a per-row “Why am I seeing this?” reason capsule:

Verification coverage:

The artifact triage workspace exposes related links from the selected finding, but those links must always resolve to real Web routes or live API contracts:

Verification coverage:

3.4 Pack Registry Browser (Sprint 20260208_068)

TaskRunner pack operations now include a dedicated registry browser route:

Browser behavior:

Verification coverage:

3.5 Pipeline Run-Centric View (Sprint 20260208_069)

Release Orchestrator now provides a unified pipeline run-centric surface that links release status, approvals, deployment progress, evidence state, and first-signal telemetry:

Path note (verified against source): This feature now lives under features/release-orchestrator/, not the historical features/release-jobengine/ path. The release-jobengine directory no longer exists. Paths below are corrected to the current release-orchestrator tree.

Run-centric behavior:

Verification coverage:

3.6 Reachability Center Coverage Summary (Sprint 20260208_070)

Reachability Center now includes explicit asset/sensor coverage summaries and missing-sensor indicators:

Coverage behavior:

3.7 SBOM Graph Reachability Overlay with Time Slider (Sprint 20260208_071)

Graph explorer overlay behavior now supports deterministic lattice-state reachability halos with temporal snapshot exploration:

Behavior details:

Verification coverage:

3.8 Active-Surface Verification Lane and Setup Wizard Styling (Sprint 20260405_002 / 005)

Focused verification and bundle-polish notes for the shipped surfaces:

Real-read model and absence-state boundary

3.9 Live Graph Asset Inventory (Sprint 20260430_072)

The Console now exposes a tenant-scoped Asset Registry v1 inventory surface:

Inventory behavior:

Verification coverage:

3.10b Compliance Profile Selector (Sprint 20260503_007)

Console panel under Setup -> Crypto Providers (/setup/crypto-providers) lets a tenant administrator change the active Cryptography:Compliance:Profile at runtime. Implementation:

Verification coverage:

See docs/modules/cryptography/architecture.md §11 for the full schema/endpoint/audit contract.

3.10 Live NIS2 Incident Reporting Dashboard (Sprint 20260430_053)

The Console NIS2 incident dashboard is bound to Notify-owned persisted regulatory timeline state:

Dashboard behavior:

Verification coverage:

3.10c Assurance Framework Tabs (Sprint 20260525_007)

The /assurance and /compliance pack landing page keeps live readiness summary, operator configuration, and the comparison matrix visible, but the heavy framework detail cards are segmented through the canonical <stella-page-tabs> component:

The tabbed detail area must not imply legal certification. Disabled packs still mean optional tenant setup choices, not failed compliance. Readiness statuses continue to come from Authority, ExportCenter, and Notify APIs.

Verification coverage:

3.11 Actor Identity Consumer (Sprint 20260519_079)

The console renders actor identity through the redaction-aware ActorIdentityProjection instead of reading actor PII (name/email) directly from per-service audit rows. This guarantees the GDPR Art. 17 erasure shape can never be silently missed in the UI.

Backend read endpoint: src/Platform/StellaOps.Platform.WebService/Endpoints/ActorIdentityEndpoints.cs projects a tenant-scoped actor ref via ActorIdentityRedactor. It authorizes on the any-of ActorIdentityRead policy — ui.read (any authenticated console user) OR platform:sar:read (a SAR-privileged caller). It was previously gated on the GDPR platform.subject_access.read scope alone, which the seeded admin does not hold, so every actor rendered as an unattributed raw GUID (Fable audit i5 #17).

Verification coverage:

See docs/modules/policy/data-handling.md for the read-path redaction shape and the SAR Ed25519 DSSE signature contract.

3.12 Audit-bundle content-verification boundary

The evidence-bundle wizard treats Completed as job lifecycle state, not proof that every requested evidence producer emitted an artifact. Its status read preserves ExportCenter’s typed producerGaps and renders each gap as attributable missing evidence. The Console never synthesizes placeholder support for VEX, policy-evaluation, or attestation producers that the backend reports as unimplemented or unavailable. A producer gap also overrides a contradictory positive path-presence result, keeping the combined state fail closed.

On download, the Console reads GET /v1/audit-bundles/{bundleId}/index and cross-checks every indexed artifact, VEX-decision, and attestation path against the exact ZIP central directory. A positive category-presence result requires a typed index reference with a sha256 digest and a matching non-directory, non-empty ZIP entry. Missing or incomplete index metadata, bundle-id drift, invalid EOCD/central-directory bounds, and missing/empty indexed paths produce an explicit unverified state. Filename patterns are not evidence. Selecting zero categories produces a neutral “No evidence categories were selected” state, never a verification success.

This browser check verifies indexed presence only; it does not inflate entries or recompute their content digests. The archive remains downloadable for manual or offline cryptographic verification, with that limitation stated beside the result.

Verification coverage:


4) Authentication, guards & interceptors

Authentication is OIDC Authorization Code + PKCE against the Authority module, with DPoP sender-constrained tokens. The implementation lives under src/app/core/auth/.

Operator Decision-Signing UX

The Console exposes operator decision-signing in two places:

  1. /administration/profilecontains the in-browser enrollment panel for NIST device keys. It routes to the real ConsoleProfileComponent — which hosts DecisionSigningEnrollmentPanelComponent — at app.routes.ts:284-291. Do not send operators to /console/profile: that path loads InsufficientPermissionsComponent (title “Insufficient Permissions”, app.routes.ts:570-576) and is the failure target that requireScopesGuard / requireAnyScopeGuard redirect to — an operator following it lands on an access-denied page instead of the enrollment panel. (The rest of the administration/* block is legacy redirectTo shims; this child is a real, non-redirect route.) On that page the operator chooses an issuer namespace and a browser-supported NIST algorithm, completes FreshAuthService.requireFreshAuth('Enroll a decision-signing key'), then uploads only Authority-compatible public DSSE key material. The private key is non-custodial: it stays as a non-extractable IndexedDB CryptoKey. If the device is lost, the operator enrolls a new key; Stella cannot recover the old private key.
  2. Release approvals and Policy exception approve/reject actions open DecisionSigningModalComponent when the API metadata says signing is required. The modal starts with the fresh-auth step-up, signs the server-prepared PAE bytes through OperatorDecisionSigningService, submits the DSSE envelope, and renders the verified keyid, algorithm, envelope digest, and “signed by” identity returned by the flow.

The browser algorithm matrix is fixed by WebCrypto capability, not by server verification support:

Region / algorithm familyIn-browser enrollment/signingRouting
NIST ECDSA ES256 / ES384 / ES512Supported when the browser exposes the curve. ES256 is the default.Console enrollment and signing modal.
RSA RS* / PS*Supported only where WebCrypto exposes the required key generation/signing algorithm.Console enrollment where feature detection passes; otherwise CLI.
ED25519 / EdDSANot reliable across WebCrypto implementations.CLI.
SM2 / SM3Not supported by WebCrypto.CLI or smartcard/plugin.
GOST12-256 / GOST12-512Not supported by WebCrypto.CLI or smartcard/plugin.
eIDAS QES / qualified certificatesNot supported by WebCrypto.CLI plus QSCD/smartcard path.

For non-NIST metadata (providerHint = sm, gost, or eidas), the modal does not call WebCrypto or submit a browser-produced envelope. It renders the exact CLI command shape (stella release approve <id> --sign ... or stella exception approve|reject <id> --sign ...) so the operator follows the regional signing path.

The Console must never build the live operator-decision@v1 payload or PAE by canonicalizing JSON locally. The backend is the canonicalization authority because its CanonJson output is not browser-reproducible. The only FE-side PAE construction is the offline conformance regression that feeds the frozen WS3 canonical payload bytes into dssePreAuthEncode and compares them with the fixture’s expected PAE bytes.


5) Configuration

Configuration has two layers.

Build-time flags (src/environments/environment.ts / environment.prod.ts) — minimal, no secrets or hostnames:

// environment.ts (dev)
export const environment = {
  production: false,
  apiBaseUrl: '/api',
  authEnabled: true,
  debugMode: true,
};

Runtime configuration is the authoritative source and is fetched at bootstrap by AppConfigService.load() (wired via provideAppInitializer in app.config.ts). It is typed by AppConfig (core/config/app-config.model.ts) and exposed through the APP_CONFIG injection token. Resolution status is one of pending | loaded | missing | error; a missing/error result routes the operator to /setup (via requireConfigGuard). Key shape:

5.1 Standalone console reverse proxy

The standalone Console image (devops/docker/Dockerfile.console) serves the SPA and same-origin API routes through Nginx. Service-owned routes must appear before the generic /api/ Platform catch-all. In particular, /api/v1/vexlens proxies to vexlens.stella-ops.local; otherwise the live consensus page receives a Platform 404 even when VexLens is healthy. The proxy also uses enlarged upstream header buffers because the first-party Console requests the full operator scope set and a successful Authority authorize response can exceed Nginx’s default single-page header buffer. Keep both rules covered by the fresh-DB VexLens live specs under src/Web/StellaOps.Web/e2e/.


6) Signed Score + Vulnerability Detail Contracts (Sprint 20260304_309)

Delivered contracts:

Scanner replay route contract (Web client):

Coverage:

Remaining planned FE capability (explicitly still planned):

7) Self-Evident UI source contracts (SPRINT_20260713_011, 2026-07-22)

Standing, spec-enforced contracts added by the W0–W7 source waves. Each is manifest-driven — the enforcement re-derives its scope from the live nav config / route tables on every run, so counts never freeze in prose.