Console (Web UI) Architecture
Verified against commit
942a18727c(2026-09-26, DOC-2 of SPRINT_20260926_001). Re-verify by reading the evidence paths listed in the Evidence section.
This is the canonical architecture dossier for the Stella Ops Console, the Angular single-page application in src/Web/StellaOps.Web/. The Console is a consumer of backend APIs: it never scans, merges advisories, evaluates policy, or signs on its own.
Companion documents:
../web/architecture.md— per-surface Console feature contracts (environment/release boundaries, dashboard bootstrap, evidence capsule resolution, audit-bundle verification, operator decision-signing UX, vulnerability-plane cutover decisions). It stays at that path because Console source comments cite its section numbers.../web/ux-rules.md— the binding UX rule catalog (UXR), enforced in part bysrc/app/core/design/ux-rules-guard.spec.ts.information-architecture.md— operator-visible navigation, role paths and action boundaries.
Paths below are relative to src/Web/StellaOps.Web/ unless they start with src/, devops/ or docs/.
0) Mission and boundaries
The Console gives operators one place to:
- see deployment and compliance posture (what is deployed where, policy drift, open exceptions, evidence export);
- run release control (topology, promotion paths, deployments, approvals);
- triage security risk (findings, VEX, reachability, exception impact);
- let service contributors see their own image candidates and request updates;
- administer tenants, identity, integrations, trust and signing.
Boundaries:
- Frontend only. All state of record lives in backend services reached through the router gateway.
- No client-side evidence fabrication (see §9, Frontend truthfulness contract).
- Tokens and PKCE login state live in
sessionStorageand memory; DPoP key material in IndexedDB; never inlocalStorage(§4).
1) Technology baseline
Verified in package.json and angular.json:
| Concern | Choice |
|---|---|
| Framework | Angular 21 (@angular/core), standalone components, Signals |
| Language | TypeScript 5.9 |
| UI kit | House components and SCSS design tokens (src/styles/, src/styles/tokens/); Angular Material and CDK 21 are dependencies but frozen for new surfaces (UXR catalog) |
| State | Angular Signals and hand-rolled stores (for example core/auth/auth-session.store.ts); no @ngrx/* dependency |
| Transport | HttpClient + RxJS; Server-Sent Events via EventSource in selected clients (core/api/console-status.client.ts, export-center.client.ts, console-export.client.ts) |
| Visualization | d3, @viz-js/viz (Graphviz/WASM), mermaid |
| Editors / Markdown | monaco-editor (lazy-loaded), yaml, marked, ngx-markdown |
| Build | @angular/build:application (esbuild); output dist/stellaops-web/ (browser/ sub-folder) |
| Unit tests | Vitest through @angular/build:unit-test; batched by scripts/run-unit-test-batches.mjs |
| E2E / a11y | Playwright (@playwright/test, @axe-core/playwright); several playwright.*.config.ts lanes |
| Component workshop | Storybook (@storybook/angular) |
2) Source layout
src/Web/StellaOps.Web/
src/app/
core/ cross-cutting singletons: api/ (typed clients + api-path-prefixes.ts),
auth/ (OIDC, PKCE, DPoP, route access, interceptors), config/ (runtime
config), context/ (platform context + URL sync), i18n/, navigation/,
http/, design/ (UX guard specs), ...
shared/ reusable components, directives, pipes, services
layout/ app shell, sidebar, topbar, breadcrumb, global search, overlays
routes/ top-level lazy route aggregators (ops, security-risk, releases,
evidence, compliance, setup, topology, triage, mission-control, ...)
features/ one folder per surface, lazy-loaded
app.config.ts ApplicationConfig: router, HTTP, interceptors, DI providers
app.routes.ts top-level route table
src/i18n/ embedded offline translation bundles
src/environments/ build-time flags only (no hostnames, no secrets)
e2e/, tests/ Playwright specs
scripts/ dev server, test batching, live harnesses, guard scripts
Features are wired through loadComponent / loadChildren, so each surface builds as its own lazy chunk. HTTP DTOs live beside their clients as core/api/*.client.ts and *.models.ts.
3) Navigation and route shells
Two layers must not be confused:
- Route shells — top-level paths in
src/app/app.routes.ts:''(Dashboard,DashboardV3Component),mission-control,environments,releases,developer(My services),security,triage(Vulnerabilities),evidence,compliance,inventory,ops,setup,settings,console-admin,notifications,docs, plus the unauthenticatedsignin,signout,logged-out,auth/callback,auth/silent-refresh./assurance/*is a redirect to/compliance/*; theadministration/*block is redirects exceptadministration/profile, which loads the operator profile and decision-signing enrollment panel.console/profileis the “Insufficient Permissions” page that scope guards redirect to. The wildcard route rendersNotFoundComponent. - Sidebar groups —
NAVIGATION_GROUPSinsrc/app/core/navigation/navigation.config.tsis the source of truth for what the operator sees: Home, Vulnerabilities (risk), Deployment (releases), Proof, Operate, plus User Preferences in the user menu. Operate is organised into four shelves (Operations, Connections, Access, Settings); a shelf link resolves to its first scope-visible child.
Older deep links are preserved as redirectTo entries (LEGACY_REDIRECT_ROUTES in src/app/routes/legacy-redirects.routes.ts and inline children in app.routes.ts). Treat these as the router’s current behavior, not as supported product routes.
Per-surface behavior and its guards are documented in information-architecture.mdand in the feature contracts in ../web/architecture.md.
4) Authentication, session and authorization
4.1 OIDC with PKCE and DPoP
- Authorization Code + PKCE against Authority (
core/auth/authority-auth.service.ts,core/auth/pkce.util.ts). Callbacks land on/auth/callbackand/auth/silent-refresh. The Console’s Authority client isstella-ops-ui(devops/etc/authority/plugins/standard.yaml); its redirect and post-logout URIs are expanded fromSTELLAOPS_CONSOLE_PUBLIC_ORIGINS, so a new install origin needs that variable, not a code change. - DPoP: a WebCrypto ECDSA key pair (
ES256default,ES384supported) is generated and stored in IndexedDB (core/auth/dpop/dpop-key-store.ts).EdDSAis rejected at runtime (dpop-key-store.tsthrowsEdDSA DPoP keys are not yet supported.). Proofs and nonce retries are handled bycore/auth/auth-http.interceptor.ts. - Refresh: access tokens refresh ahead of expiry (
ACCESS_TOKEN_REFRESH_THRESHOLD_MSincore/auth/auth-session.model.ts;authority.refreshLeewaySeconds, default 60). - Sign-out routes through
/signout(confirmation) and/logged-out; the Authority session is ended best-effort against the configuredlogoutEndpoint.
4.2 Session storage
Session metadata is kept in sessionStorage by core/auth/auth-storage.service.ts, which sweeps entries older than 10 minutes. The config-source cookie stellaops_env_settings_url is the only cookie the config loader writes.
4.3 Scopes and route access
- Authorization is scope-based.
core/auth/scopes.tsmirrors the scope names; the canonical catalog issrc/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs.scopes.tsstill declares three UI-local names that are not in the canonical catalog (policy:evaluate,tenant:admin,risk:read); do not rely on Authority issuing them. core/auth/route-access.ts(NAV_ACCESS,ROUTE_FAMILY_ACCESS) is the contract shared by navigation, parent tabs andcanMatchguards. Standard rules letadminorui.adminbypass the listed scopes; rules built withexplicitAllOf/explicitAnyOfmodel the*Explicit*guards and allow no bypass.- Every protected shell uses the
canMatchchainrequireConfigGuard → requireBackendsReachableGuard → requireAuthGuard → <scope guard>. Unauthenticated navigation goes to/signin, which forwards to Authority with the deep link asreturnUrl; a failed scope guard goes to/console/profile. - The scopes the Console requests are set by the runtime config (
authority.scope); they must stay a subset of thestella-ops-uiclient’sallowedScopesindevops/etc/authority/plugins/standard.yaml.
4.4 HTTP interceptors
Registered in src/app/app.config.ts, in this order: AuthHttpInterceptor (bearer + DPoP, refresh), OperatorMetadataInterceptor, TenantHttpInterceptor (canonical X-StellaOps-* tenant/project headers from core/http/stella-ops-headers), GlobalContextHttpInterceptor (region/environment context as comma-delimited sets).
Platform context (tenant, regions, environments) is mirrored into the URL by core/context/platform-context-url-sync.service.ts: once the session is authenticated and the initial navigation has settled, a context change performs one replaceUrl navigation so the router state, address bar and queryParamsHandling="merge" links agree. Setup-wizard, auth, sign-in/out and console routes are excluded. Regression coverage: src/tests/context/platform-context-url-sync.service.spec.ts.
5) Runtime configuration
The SPA is configured at runtime, so one build serves any deployment. AppConfigService.load() (core/config/app-config.service.ts, run by provideAppInitializer in app.config.ts) resolves the document in this order:
?envSettings=<url>query parameter (remembered in thestellaops_env_settings_urlcookie);- the URL in that cookie;
/platform/envsettings.json— served anonymously by Platform (src/Platform/StellaOps.Platform.WebService/Endpoints/EnvironmentSettingsEndpoints.cs) and routed by the gateway;/config.json— deprecated fallback that logs a warning.
If none resolves, the status is missing and requireConfigGuard sends the operator to /setup. The shape is AppConfig in core/config/app-config.model.ts:
authority:issuer,clientId,authorizeEndpoint,tokenEndpoint,logoutEndpoint?,redirectUri,silentRefreshRedirectUri?,postLogoutRedirectUri?,scope,audience,dpopAlgorithms?,refreshLeewaySeconds?;apiBaseUrls: per-service base URLs;normalizeApiBaseUrlscollapses internal*.stella-ops.localhosts to gateway-relative paths so calls stay same-origin;- optional
telemetry,welcome(not rendered),doctor,setup(setup-wizard resume state);quickstartModeis deprecated and ignored.
There is no STELLAOPS_BACKEND_URL variable. Feature visibility is decided by scope guards, not by configuration flags. The lab values live in devops/compose/envsettings-override.json (read by app-config.service.spec.ts).
6) API clients and the prefix registry
- One typed client per backend surface under
core/api/. core/api/api-path-prefixes.tsis the one registry of backend path prefixes. Host resolution stays inapp.config.tsprovider factories; path prefixes live only in the registry. The retired vulnerability-plane keys (concelier,concelierJobs,vexHub,vexLens) were removed with the surfaces that used them.- The registry is guarded by the literal sweeps in
tools/scripts/validate/:check-console-dead-vuln-prefixes.sh(fails on calls to routes removed with the retired Concelier/VEX plane) and the per-planecheck-console-*-prefix-literals.shscripts (AdvisoryAI, Findings, Graph, Integrations, issuer directory, JobEngine, Notify, OfflineKit), each with--self-test. - Examples of current prefixes (read the registry for the full list):
vulnerabilitiesHub=/api/vulnerabilities/v1,offlineKit=/api/offlinekit/v1,exportCenter=/api/offlinekit/v1/exports,findingsVexDecisions=/api/findings/v1/vex-decisions. - Policy import/export/validate/evaluate is server-side through
core/api/policy-interop.service.ts(/api/v1/policy/interop). There is no in-browser policy evaluator.
7) Internationalization, theming, accessibility
core/i18n/i18n.service.tsloads translations from Platform (/platform/i18n/{locale}.json) with embedded fallback bundles insrc/i18n/(en-US,de-DE,bg-BG,ru-RU,es-ES,fr-FR,uk-UA,zh-TW,zh-CN). The locale catalog comes fromGET /api/v1/platform/localization/locales(locale-catalog.service.ts); the user preference is read and written through/api/v1/platform/preferences/language(user-locale-preference.service.ts).- Theming uses CSS custom properties from
src/styles/tokens/. Interactive and status colors must use the shared tokens; the binding rules are in../web/ux-rules.md. - Accessibility target: WCAG AA. The Playwright gate
npm run test:a11y(tests/e2e/accessibility.spec.ts) runs axe with thewcag2aandwcag2aarule tags. Seeaccessibility.md.
8) Build, serving and deployment
- Build.
npm run build(ng build) emitsdist/stellaops-web/browser/.angular.jsonalso copies the repositorydocs/tree (minus QA, implplan, dev and similar folders) into thedocs-contentasset set, which the in-app/docsviewer serves. Moving or renaming a doc changes that viewer’s content. - Dev compose (
devops/compose/docker-compose.stella-services.yml): theconsole-builderinit container (imagestellaops/console:dev, built fromdevops/docker/Dockerfile.console) copies the built SPA into theconsole-distvolume and exits;router-gatewaywaits for it (service_completed_successfully), mountsconsole-distat/app/wwwrootread-only and serves it through itsStaticFilesroute with SPA fallback (src/Router/StellaOps.Gateway.WebService/appsettings.json,devops/compose/router-gateway-local.json). There is no separate UI container in the running estate. - Release bundle (
devops/release/bundle/docker-compose.yml): there is noconsole-builderand no console image.devops/release/bundle/tools/build-and-publish.shbakes the tested Angular dist into therouter-gatewayimage at/app/wwwrootand records its tree hash in the bundle manifest;generate-bundle.pydropsconsole-builderand theconsole-distmount when rendering the bundle. devops/docker/Dockerfile.consolealso configures Nginx with same-origin API proxy locations so the image can run as a standalone console. Neither the dev estate nor the release bundle runs it that way; keep its proxy rules consistent with the gateway routes if you change them.- Local development:
npm startrunsscripts/serve.js(defaulthttp://localhost:4200/);npm run serve:testbinds127.0.0.1:4400for Playwright.devops/compose/docker-compose.dev-ui.ymloverlays a local dist onto the gateway.
9) Runtime contracts enforced in source
- Runtime API provider contract. Production DI binds HTTP clients. Mock, stub and in-memory clients are allowed only in specs, Storybook and explicit local fixtures;
provideScoreApi()/provideReplayApi()default to HTTP and accept only the named'local-harness'mode.scripts/guard-runtime-mocks.mjs(npm run guard:runtime-mocks) scans runtime config, route files and those helpers, and rejects hardcoded decision data on routed release-approval surfaces. - Frontend truthfulness contract. The Console formats and explains backend results; it must not locally simulate DSSE verification, Rekor inclusion, policy verdict changes, export signing, air-gap sealing state, what-if confidence, or audit-pack signing. When a backend result is unavailable, the surface renders an unavailable or error state instead of an empty healthy one. The per-surface rules are in
../web/architecture.md. - State, naming and disabled-reason contracts.
core/navigation/state-contract.ts,core/navigation/empty-state-copy-contract.tsandcore/navigation/naming-contract.tsare enforced by specs undersrc/tests/navigation/andsrc/tests/ux/; details in../web/architecture.md§7.
10) Selected surfaces
These surfaces carry architecture-level rules. Other per-surface contracts are in ../web/architecture.md§3 and in the topic folders of this dossier.
10.1 My services (Developer Services)
- Route
/developer/services, lazy-loaded fromfeatures/developer-services/; guarded byrequireDeveloperServicesGuard. - The page reads the caller’s profile first. Missing ownership claims render one access gate that names the account, its subject, the contributor role and the developer ABAC claims to grant, with no tenant-wide reads and no tenant-wide Deployments or Security findings links.
- Releases are grouped by their recorded name; the recorded components of each family’s latest release (its most recently created version) are read (
GET /api/v1/release-orchestrator/releases/{id}/components). A service is shown when the ownership claims name the release (name or id), or one of its recorded components by registry component id, name, source repository or image repository (namespace on a path boundary), with Release Orchestrator’s claim tokens (a service claim also matches by its last path segment and by the part before its last colon). Only owned components with asha256:digest are candidates; another component of the same release never is. - The selected service leads with the owned service (release and component), the current digest the latest succeeded deployment to the environment records for that component (from the deployment summary’s recorded
components), the candidate digest, the target and its mode, and the release gate decision with its recorded pending approvals. Absent values are named (Not recorded,Not deployed,Not evaluated). The deployment list is read page by page (100 per page, at most five pages);Not deployedis shown only when the read reached the end of the list, otherwise the page names how many deployments it read. - Direct image updates are offered only when the token has explicit
release:write, service ownership, environment constraints,directUpdateAllowed=trueand a candidate digest the release records; otherwise the page files an approval request. Both name the latest release (releaseId), the recorded component (serviceId) and the recorded release name (serviceName); Release Orchestrator repeats the checks on its records and its refusal reasons, message or problem detail are shown. A pasted digest the release does not record is not sent: the page creates the successor version (POST /api/v1/release-orchestrator/releases/{id}/versions/from-artifact-update), also from a409 release_artifact_update_requires_new_versionsuggestion, and re-reads. - Scan history: for the selected service, the image digests recorded on the components of its latest Release Orchestrator release are read one by one from Scanner’s digest-detail route (
scanner.scans.by-digest.detail,scanner:read, tenant from the token), through the Router-published/api/v1/scansgroup (the configured/scannerbase has no gateway route). Each image shows Scanner’s latest attempt with its scan id, and the last completed scan separately when a newer attempt has not completed. Findings are not consulted, so a completed scan without findings appears. Scanner serves no list of earlier attempts, so they are not shown. Images of the service’s earlier releases are not read. Services without a release, a latest release without components, components without a digest, a component read Release Orchestrator refuses (developer_projection_forbidden) and a failed Scanner read are each named, never shown as an empty history. Build attribution is shown only when the component records a provenance attestation reference. Images page in a fixed order (service, then component), so Refresh keeps the same images on a page and re-reads only their Scanner state. The Developer dashboard card “Your builds & SBOM scans” shows the first five images of the same history across all owned services (features/developer-services/contributor-scan-history.*). The card builds the owned services from the header-scoped Release Orchestrator release list alone, matching ownership on the release name and id (it reads no components), with no deployment or findings read, so a failure of either owner does not hide it.
10.2 Registry images
/security/image-inventory(cross-registry Images) and/setup/integrations/registry-browser/:registryIdshare one readiness helper,features/registry-browser/registry-image-readiness.ts. Durable readiness, latest scan attempt and policy verdict are shown as separate facts.- Opening an image from
/security/image-inventoryhands the Image Security dossier the registry integration id, the registry’s full digest-pinned reference, the digest and the descriptive tag (core/api/image-scan-handoff.ts). The dossier rescans and prefills/security/scanwithname@digest, so a moved tag cannot select other bytes; a reference that names a different digest, a malformed reference and a digest without any known reference each render an explicit state instead of a scan action.
10.3 Global search and assistant
- The top-bar search is the primary entry. Page-aware chips and self-serve questions are declared in
core/services/search-context.registry.tsand governed bysearch-chip-context-contract.mdandsearch-self-serve-contract.md. The AdvisoryAI assistant opens in a shell-level drawer that keeps the operator on the current route.
10.4 Crypto providers and compliance profile
/setup/crypto-providers(features/settings/crypto-providers/) lists providers and lets an administrator switch the tenant compliance profile throughPUT .../compliance-profile(core/api/crypto-provider.client.ts) undercrypto:profile:admin. See../web/architecture.md§3.10b.
11) Testing
- Unit/component: Vitest (
npm test, batched;npm run test:ciadds the evidence bundle-mutation guard). Targeted runs usevitest.codex.config.ts;npm run test:active-surfacesruns the active-surface lane. - E2E: Playwright (
npm run test:e2e; live harnesses underscripts/). - Guards:
npm run guard:runtime-mocks; UX guard specsrc/app/core/design/ux-rules-guard.spec.ts; console prefix sweeps intools/scripts/validate/.
12) Not implemented / design targets
- Front-end route plug-ins (signed remote JS bundles loaded at runtime) are not implemented: there is no module-federation loader or plug-in manifest verifier. The Console only views backend plug-ins.
- Performance budgets (initial JS size, time-to-interactive, large-table and graph frame-rate targets) are design targets; no enforcing budget beyond the
angular.jsonbuild budgets was verified. - Security headers (CSP, HSTS, frame options) are set by the serving tier, not by the SPA; the exact header set was not verified in this revision.
Evidence
| Claim | Source |
|---|---|
| Framework, dependency and script set | src/Web/StellaOps.Web/package.json |
| Builders, output path, docs-content asset mapping | src/Web/StellaOps.Web/angular.json |
Route shells, guards, redirects, console/profile, administration/profile | src/Web/StellaOps.Web/src/app/app.routes.ts, src/app/routes/legacy-redirects.routes.ts |
| Sidebar groups and Operate shelves | src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts |
| Route access model (standard vs explicit rules) | src/Web/StellaOps.Web/src/app/core/auth/route-access.ts |
| UI scope names; UI-local scopes | src/Web/StellaOps.Web/src/app/core/auth/scopes.ts; canonical src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs |
| PKCE, DPoP key store, EdDSA rejection | src/app/core/auth/pkce.util.ts, authority-auth.service.ts, dpop/dpop-key-store.ts |
| Refresh threshold | src/app/core/auth/auth-session.model.ts |
| Session storage sweep | src/app/core/auth/auth-storage.service.ts |
| Interceptor order | src/Web/StellaOps.Web/src/app/app.config.ts |
| Context URL sync | src/app/core/context/platform-context-url-sync.service.ts |
| Tenant headers | src/app/core/auth/tenant-http.interceptor.ts, src/app/core/http/stella-ops-headers.ts |
| Runtime config chain and shape | src/app/core/config/app-config.service.ts, app-config.model.ts |
| envsettings endpoint | src/Platform/StellaOps.Platform.WebService/Endpoints/EnvironmentSettingsEndpoints.cs; gateway route in src/Router/StellaOps.Gateway.WebService/appsettings.json |
| Console Authority client, public-origin expansion | devops/etc/authority/plugins/standard.yaml (stella-ops-ui, STELLAOPS_CONSOLE_PUBLIC_ORIGINS) |
| Lab runtime config | devops/compose/envsettings-override.json |
| Prefix registry | src/app/core/api/api-path-prefixes.ts |
| Prefix sweeps | tools/scripts/validate/check-console-dead-vuln-prefixes.sh, tools/scripts/validate/check-console-*-prefix-literals.sh |
| SSE clients | src/app/core/api/console-status.client.ts, export-center.client.ts, console-export.client.ts |
| Policy interop | src/app/core/api/policy-interop.service.ts |
| i18n bundles and Platform endpoints | src/app/core/i18n/i18n.service.ts, locale-catalog.service.ts, user-locale-preference.service.ts |
| Dev serving (console-builder, console-dist, gateway StaticFiles) | devops/compose/docker-compose.stella-services.yml, devops/docker/Dockerfile.console, devops/compose/router-gateway-local.json |
| Release serving (dist baked into gateway image) | devops/release/bundle/tools/build-and-publish.sh, devops/release/bundle/tools/generate-bundle.py, devops/release/bundle/docker-compose.yml |
| Runtime mock guard | src/Web/StellaOps.Web/scripts/guard-runtime-mocks.mjs |
| Developer services | src/app/features/developer-services/ |
| Registry image readiness | src/app/features/registry-browser/registry-image-readiness.ts |
| Search context registry | src/app/core/services/search-context.registry.ts |
| Crypto provider client and route | src/app/core/api/crypto-provider.client.ts, src/app/routes/setup.routes.ts |
