Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

3
src/Web/StellaOps.Web/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.cache/
coverage/
dist/

View File

@@ -0,0 +1,24 @@
# StellaOps Web Frontend
## Mission
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
## Team Composition
- **UX Specialist** defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
- **Angular Engineers** implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
## Operating Principles
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
## Key Paths
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
- `docs/` — UX specs and mockups (to be added).
- `ops/` — Web deployment manifests for air-gapped environments (future).
## Coordination
- Sync with DevEx for project scaffolding and build pipelines.
- Partner with Docs Guild to translate UX decisions into operator guides.
- Collaborate with Security Guild to validate authentication flows and session handling.

View File

@@ -0,0 +1,63 @@
# StellaOps Web
Offline-first expectations mean the workspace must restore dependencies and run tests without surprise downloads. Follow the deterministic install flow below before running commands on an air-gapped runner.
## Deterministic Install (CI / Offline)
1. Pick an npm cache directory (for example `/opt/stellaops/npm-cache`) that you can copy into the Offline Kit.
2. On a connected machine, export `NPM_CONFIG_CACHE` to that directory and run `npm run ci:install`. This executes `npm ci --prefer-offline --no-audit --no-fund`, seeding the cache without audit/fund traffic.
3. Provision a headless Chromium binary by either:
- installing `chromium`, `chromium-browser`, or `google-chrome-stable` through your distribution tooling; or
- downloading one via `npx @puppeteer/browsers install chrome@stable --path .cache/chromium` and archiving the resulting `.cache/chromium/` directory.
4. Transfer the npm cache (and optional `.cache/chromium/`) to the offline runner, export `NPM_CONFIG_CACHE`, then execute `npm run ci:install` again.
5. Use `npm run verify:chromium` to confirm Karma can locate a browser. `npm run test:ci` enforces this check automatically.
See `docs/DeterministicInstall.md` for a detailed operator checklist covering cache priming and Chromium placement.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
- `npm test` executes `ng test --watch=false` once after verifying a Chromium binary.
- `npm run test:ci` first calls `npm run verify:chromium` to guarantee CI/offline setups fail fast when a browser is missing.
- `npm run test:watch` keeps Karma in watch mode for local development.
`verify:chromium` prints every location inspected (environment overrides, system paths, `.cache/chromium/`). Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` if you host the binary in a non-standard path.
## Runtime configuration
The SPA loads environment details from `/config.json` at startup. During development we ship a stub configuration under `src/config/config.json`; adjust the issuer, client ID, and API base URLs to match your Authority instance. To reset, copy `src/config/config.sample.json` back to `src/config/config.json`:
```bash
cp src/config/config.sample.json src/config/config.json
```
When packaging for another environment, replace the file before building so the generated bundle contains the correct defaults. Gateways that rewrite `/config.json` at request time can override these settings without rebuilding.
## End-to-end tests
Playwright drives the high-level auth UX using the stub configuration above. Ensure the Angular dev server can bind to `127.0.0.1:4400`, then run:
```bash
npm run test:e2e
```
The Playwright config auto-starts `npm run serve:test` and intercepts Authority redirects, so no live IdP is required. For CI/offline nodes, pre-install the required browsers via `npx playwright install --with-deps` and cache the results alongside your npm cache.
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@@ -0,0 +1,179 @@
# TASKS — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-AOC-19-001 `Shared AOC guard primitives` | DOING (2025-10-26) | BE-Base Platform Guild | — | Provide `AOCForbiddenKeys`, guard middleware/interceptor hooks, and error types (`AOCError`, `AOCViolationCode`) for ingestion services. Publish sample usage + analyzer to ensure guard registered. |
> 2025-10-26: Introduced `StellaOps.Aoc` library with forbidden key list, guard result/options, and baseline write guard + tests. Middleware/analyzer wiring still pending.
| WEB-AOC-19-002 `Provenance & signature helpers` | TODO | BE-Base Platform Guild | WEB-AOC-19-001 | Ship `ProvenanceBuilder`, checksum utilities, and signature verification helper integrated with guard logging. Cover DSSE/CMS formats with unit tests. |
| WEB-AOC-19-003 `Analyzer + test fixtures` | TODO | QA Guild, BE-Base Platform Guild | WEB-AOC-19-001 | Author Roslyn analyzer preventing ingestion modules from writing forbidden keys without guard, and provide shared test fixtures for guard validation used by Concelier/Excititor service tests. |
> Docs alignment (2025-10-26): Analyzer expectations detailed in `docs/ingestion/aggregation-only-contract.md` §3/5; CI integration tracked via DEVOPS-AOC-19-001.
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-POLICY-20-001 `Policy endpoints` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-ENGINE-20-001, POLICY-ENGINE-20-004 | Implement Policy CRUD/compile/run/simulate/findings/explain endpoints with OpenAPI, tenant scoping, and service identity enforcement. |
| WEB-POLICY-20-002 `Pagination & filters` | TODO | BE-Base Platform Guild | WEB-POLICY-20-001 | Add pagination, filtering, sorting, and tenant guards to listings for policies, runs, and findings; include deterministic ordering and query diagnostics. |
| WEB-POLICY-20-003 `Error mapping` | TODO | BE-Base Platform Guild, QA Guild | WEB-POLICY-20-001 | Map engine errors to `ERR_POL_*` responses with consistent payloads and contract tests; expose correlation IDs in headers. |
| WEB-POLICY-20-004 `Simulate rate limits` | TODO | Platform Reliability Guild | WEB-POLICY-20-001, WEB-POLICY-20-002 | Introduce adaptive rate limiting + quotas for simulation endpoints, expose metrics, and document retry headers. |
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-GRAPH-21-001 `Graph endpoints` | BLOCKED (2025-10-27) | BE-Base Platform Guild, Graph Platform Guild | GRAPH-API-28-003, AUTH-VULN-24-001 | Add gateway routes for graph versions/viewport/node/path/diff/export endpoints with tenant enforcement, scope checks, and streaming responses; proxy Policy Engine diff toggles without inline logic. Adopt `StellaOpsScopes` constants for RBAC enforcement. |
> 2025-10-27: Graph API gateway cant proxy until upstream Graph service (`GRAPH-API-28-003`) and Authority scope update (`AUTH-VULN-24-001`) publish stable contracts.
| WEB-GRAPH-21-002 `Request validation` | BLOCKED (2025-10-27) | BE-Base Platform Guild | WEB-GRAPH-21-001 | Implement bbox/zoom/path parameter validation, pagination tokens, and deterministic ordering; add contract tests for boundary conditions. |
> 2025-10-27: Blocked on `WEB-GRAPH-21-001`; request envelope still undefined.
| WEB-GRAPH-21-003 `Error mapping & exports` | BLOCKED (2025-10-27) | BE-Base Platform Guild, QA Guild | WEB-GRAPH-21-001 | Map graph service errors to `ERR_Graph_*`, support GraphML/JSONL export streaming, and document rate limits. |
> 2025-10-27: Depends on core Graph proxy route definitions.
| WEB-GRAPH-21-004 `Overlay pass-through` | BLOCKED (2025-10-27) | BE-Base Platform Guild, Policy Guild | WEB-GRAPH-21-001, POLICY-ENGINE-30-002 | Proxy Policy Engine overlay responses for graph endpoints while keeping gateway stateless; maintain streaming budgets and latency SLOs. |
> 2025-10-27: Requires base Graph routing plus Policy overlay schema (`POLICY-ENGINE-30-002`).
## Graph Explorer (Sprint 28)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-GRAPH-24-001 `Gateway proxy refresh` | TODO | BE-Base Platform Guild | GRAPH-API-28-001, AUTH-GRAPH-21-001 | Gateway proxy for Graph API and Policy overlays with RBAC, caching, pagination, ETags, and streaming; zero business logic. |
| WEB-GRAPH-24-004 `Telemetry aggregation` | TODO | BE-Base Platform Guild, Observability Guild | WEB-GRAPH-24-001, DEVOPS-GRAPH-28-003 | Collect gateway metrics/logs (tile latency, proxy errors, overlay cache stats) and forward to dashboards; document sampling strategy. |
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-LNM-21-001 `Advisory observation endpoints` | TODO | BE-Base Platform Guild, Concelier WebService Guild | CONCELIER-LNM-21-201 | Surface new `/advisories/*` APIs through gateway with caching, pagination, and RBAC enforcement (`advisory:read`). |
| WEB-LNM-21-002 `VEX observation endpoints` | TODO | BE-Base Platform Guild, Excititor WebService Guild | EXCITITOR-LNM-21-201 | Expose `/vex/*` read APIs with evidence routes and export handlers; map `ERR_AGG_*` codes. |
| WEB-LNM-21-003 `Policy evidence aggregation` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-ENGINE-40-001 | Provide combined endpoint for Console to fetch policy result + source evidence (advisory + VEX linksets) for a component. |
## Policy Engine + Editor v1 (Epic 5)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-POLICY-23-001 `Policy pack CRUD` | BLOCKED (2025-10-29) | BE-Base Platform Guild, Policy Guild | POLICY-GATEWAY-18-001..002 | Implement API endpoints for creating/listing/fetching policy packs and revisions (`/policy/packs`, `/policy/packs/{id}/revisions`) with pagination, RBAC, and AOC metadata exposure. (Tracked via Sprint 18.5 gateway tasks.) |
| WEB-POLICY-23-002 `Activation & scope` | BLOCKED (2025-10-29) | BE-Base Platform Guild | POLICY-GATEWAY-18-003 | Add activation endpoint with scope windows, conflict checks, and optional 2-person approval integration; emit events on success. (Tracked via Sprint 18.5 gateway tasks.) |
| WEB-POLICY-23-003 `Simulation & evaluation` | TODO | BE-Base Platform Guild | POLICY-ENGINE-50-002 | Provide `/policy/simulate` and `/policy/evaluate` endpoints with streaming responses, rate limiting, and error mapping. |
| WEB-POLICY-23-004 `Explain retrieval` | TODO | BE-Base Platform Guild | POLICY-ENGINE-50-006 | Expose explain history endpoints (`/policy/runs`, `/policy/runs/{id}`) including decision tree, sources consulted, and AOC chain. |
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-GRAPH-24-001 `Graph endpoints` | TODO | BE-Base Platform Guild, SBOM Service Guild | SBOM-GRAPH-24-002 | Implement `/graph/assets/*` endpoints (snapshots, adjacency, search) with pagination, ETags, and tenant scoping while acting as a pure proxy. |
| WEB-GRAPH-24-004 `AOC enrichers` | TODO | BE-Base Platform Guild | WEB-GRAPH-24-001 | Embed AOC summaries sourced from overlay services; ensure gateway does not compute derived severity or hints. |
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-CONSOLE-23-001 `Global posture endpoints` | TODO | BE-Base Platform Guild, Product Analytics Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, POLICY-CONSOLE-23-001, SBOM-CONSOLE-23-001, SCHED-CONSOLE-23-001 | Provide consolidated `/console/dashboard` and `/console/filters` APIs returning tenant-scoped aggregates (findings by severity, VEX override counts, advisory deltas, run health, policy change log). Enforce AOC labelling, deterministic ordering, and cursor-based pagination for drill-down hints. |
| WEB-CONSOLE-23-002 `Live status & SSE proxy` | TODO | BE-Base Platform Guild, Scheduler Guild | SCHED-CONSOLE-23-001, DEVOPS-CONSOLE-23-001 | Expose `/console/status` polling endpoint and `/console/runs/{id}/stream` SSE/WebSocket proxy with heartbeat/backoff, queue lag metrics, and auth scope enforcement. Surface request IDs + retry headers. |
| WEB-CONSOLE-23-003 `Evidence export orchestrator` | TODO | BE-Base Platform Guild, Policy Guild | EXPORT-CONSOLE-23-001, POLICY-CONSOLE-23-001 | Add `/console/exports` POST/GET routes coordinating evidence bundle creation, streaming CSV/JSON exports, checksum manifest retrieval, and signed attestation references. Ensure requests honor tenant + policy scopes and expose job tracking metadata. |
| WEB-CONSOLE-23-004 `Global search router` | TODO | BE-Base Platform Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, SBOM-CONSOLE-23-001 | Implement `/console/search` endpoint accepting CVE/GHSA/PURL/SBOM identifiers, performing fan-out queries with caching, ranking, and deterministic tie-breaking. Return typed results for Console navigation; respect result caps and latency SLOs. |
| WEB-CONSOLE-23-005 `Downloads manifest API` | TODO | BE-Base Platform Guild, DevOps Guild | DOWNLOADS-CONSOLE-23-001, DEVOPS-CONSOLE-23-002 | Serve `/console/downloads` JSON manifest (images, charts, offline bundles) sourced from signed registry metadata; include integrity hashes, release notes links, and offline instructions. Provide caching headers and documentation. |
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-POLICY-27-001 `Policy registry proxy` | TODO | BE-Base Platform Guild, Policy Registry Guild | REGISTRY-API-27-001, AUTH-POLICY-27-001 | Surface Policy Registry APIs (`/policy/workspaces`, `/policy/versions`, `/policy/reviews`, `/policy/registry`) through gateway with tenant scoping, RBAC, and request validation; ensure streaming downloads for evidence bundles. |
| WEB-POLICY-27-002 `Review & approval routes` | TODO | BE-Base Platform Guild | WEB-POLICY-27-001, REGISTRY-API-27-006 | Implement review lifecycle endpoints (open, comment, approve/reject) with audit headers, comment pagination, and webhook fan-out. |
| WEB-POLICY-27-003 `Simulation orchestration endpoints` | TODO | BE-Base Platform Guild, Scheduler Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Expose quick/batch simulation endpoints with SSE progress (`/policy/simulations/{runId}/stream`), cursor-based result pagination, and manifest download routes. |
| WEB-POLICY-27-004 `Publish & promote controls` | TODO | BE-Base Platform Guild, Security Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add publish/sign/promote/rollback endpoints with idempotent request IDs, canary parameters, and environment bindings; enforce scope checks and emit structured events. |
| WEB-POLICY-27-005 `Policy Studio telemetry` | TODO | BE-Base Platform Guild, Observability Guild | WEB-POLICY-27-001..004, TELEMETRY-CONSOLE-27-001 | Instrument metrics/logs for compile latency, simulation queue depth, approval latency, promotion actions; expose aggregated dashboards and correlation IDs for Console. |
## Exceptions v1 (Epic 7)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-EXC-25-001 `Exceptions CRUD & workflow` | TODO | BE-Base Platform Guild | POLICY-ENGINE-70-002, AUTH-EXC-25-001 | Implement `/exceptions` API (create, propose, approve, revoke, list, history) with validation, pagination, and audit logging. |
| WEB-EXC-25-002 `Policy integration surfaces` | TODO | BE-Base Platform Guild | POLICY-ENGINE-70-001 | Extend `/policy/effective` and `/policy/simulate` responses to include exception metadata and accept overrides for simulations. |
| WEB-EXC-25-003 `Notifications & events` | TODO | BE-Base Platform Guild, Platform Events Guild | WEB-EXC-25-001 | Publish `exception.*` events, integrate with notification hooks, enforce rate limits. |
## Reachability v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-SIG-26-001 `Signals proxy endpoints` | TODO | BE-Base Platform Guild, Signals Guild | SIGNALS-24-001 | Surface `/signals/callgraphs`, `/signals/facts` read/write endpoints with pagination, ETags, and RBAC. |
| WEB-SIG-26-002 `Reachability joins` | TODO | BE-Base Platform Guild | WEB-SIG-26-001, POLICY-ENGINE-80-001 | Extend `/policy/effective` and `/vuln/explorer` responses to include reachability scores/states and allow filtering. |
| WEB-SIG-26-003 `Simulation hooks` | TODO | BE-Base Platform Guild | WEB-SIG-26-002, POLICY-ENGINE-80-001 | Add reachability override parameters to `/policy/simulate` and related APIs for what-if analysis. |
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-VULN-29-001 `Vuln API routing` | TODO | BE-Base Platform Guild | VULN-API-29-001, AUTH-VULN-29-001 | Expose `/vuln/*` endpoints via gateway with tenant scoping, RBAC/ABAC enforcement, anti-forgery headers, and request logging. |
| WEB-VULN-29-002 `Ledger proxy headers` | TODO | BE-Base Platform Guild, Findings Ledger Guild | WEB-VULN-29-001, LEDGER-29-002 | Forward workflow actions to Findings Ledger with idempotency headers and correlation IDs; handle retries/backoff. |
| WEB-VULN-29-003 `Simulation + export routing` | TODO | BE-Base Platform Guild | VULN-API-29-005, VULN-API-29-008 | Provide simulation and export orchestration routes with SSE/progress headers, signed download links, and request budgeting. |
| WEB-VULN-29-004 `Telemetry aggregation` | TODO | BE-Base Platform Guild, Observability Guild | WEB-VULN-29-001..003, DEVOPS-VULN-29-003 | Emit gateway metrics/logs (latency, error rates, export duration), propagate query hashes for analytics dashboards. |
| WEB-VEX-30-007 `VEX consensus routing` | TODO | BE-Base Platform Guild, VEX Lens Guild | VEXLENS-30-007, AUTH-VULN-24-001 | Route `/vex/consensus` APIs with tenant RBAC/ABAC, caching, and streaming; surface telemetry and trace IDs without gateway-side overlay logic. |
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-AIAI-31-001 `API routing` | TODO | BE-Base Platform Guild | AIAI-31-006, AUTH-VULN-29-001 | Route `/advisory/ai/*` endpoints through gateway with RBAC/ABAC, rate limits, and telemetry headers. |
| WEB-AIAI-31-002 `Batch orchestration` | TODO | BE-Base Platform Guild | AIAI-31-006 | Provide batching job handlers and streaming responses for CLI automation with retry/backoff. |
| WEB-AIAI-31-003 `Telemetry & audit` | TODO | BE-Base Platform Guild, Observability Guild | WEB-AIAI-31-001, DEVOPS-AIAI-31-001 | Emit metrics/logs (latency, guardrail blocks, validation failures) and forward anonymized prompt hashes to analytics. |
## Orchestrator Dashboard
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-ORCH-32-001 `Read-only routing` | TODO | BE-Base Platform Guild | ORCH-SVC-32-003, AUTH-ORCH-32-001 | Expose `/orchestrator/sources|runs|jobs|dag` read endpoints via gateway with tenant scoping, caching, and viewer scope enforcement. |
| WEB-ORCH-33-001 `Control + backfill actions` | TODO | BE-Base Platform Guild | WEB-ORCH-32-001, ORCH-SVC-33-001, AUTH-ORCH-33-001 | Add POST action routes (`pause|resume|test`, `retry|cancel`, `jobs/tail`, `backfill preview`) with proper error mapping and SSE bridging. |
| WEB-ORCH-34-001 `Quotas & telemetry` | TODO | BE-Base Platform Guild | WEB-ORCH-33-001, ORCH-SVC-33-003, ORCH-SVC-34-001 | Surface quotas/backfill APIs, queue/backpressure metrics, and error clustering routes with admin scope enforcement and audit logging. |
## Export Center
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-EXPORT-35-001 `Export routing` | TODO | BE-Base Platform Guild | EXPORT-SVC-35-006, AUTH-EXPORT-35-001 | Surface Export Center APIs (profiles/runs/download) through gateway with tenant scoping, streaming support, and viewer/operator scope checks. |
| WEB-EXPORT-36-001 `Distribution endpoints` | TODO | BE-Base Platform Guild | WEB-EXPORT-35-001, EXPORT-SVC-36-004 | Add distribution routes (OCI/object storage), manifest/provenance proxies, and signed URL generation. |
| WEB-EXPORT-37-001 `Scheduling & verification` | TODO | BE-Base Platform Guild | WEB-EXPORT-36-001, EXPORT-SVC-37-003 | Expose scheduling, retention, encryption parameters, and verification endpoints with admin scope enforcement and audit logs. |
## Notifications Studio (Epic 11)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-NOTIFY-38-001 `Gateway routing` | TODO | BE-Base Platform Guild | NOTIFY-SVC-38-004, AUTH-NOTIFY-38-001 | Route notifier APIs (`/notifications/*`) and WS feed through gateway with tenant scoping, viewer/operator scope enforcement, and SSE/WebSocket bridging. |
| WEB-NOTIFY-39-001 `Digest & simulation endpoints` | TODO | BE-Base Platform Guild | WEB-NOTIFY-38-001, NOTIFY-SVC-39-001..003 | Surface digest scheduling, quiet-hour/throttle management, and simulation APIs; ensure rate limits and audit logging. |
| WEB-NOTIFY-40-001 `Escalations & localization` | TODO | BE-Base Platform Guild | WEB-NOTIFY-39-001, NOTIFY-SVC-40-001..003 | Expose escalation, localization, channel health, and ack verification endpoints with admin scope enforcement and signed token validation. |
## Containerized Distribution (Epic 13)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-CONTAINERS-44-001 `Config discovery & quickstart flag` | TODO | BE-Base Platform Guild | COMPOSE-44-001 | Expose `/welcome` state, config discovery endpoint (safe values), and `QUICKSTART_MODE` handling for Console banner; add `/health/liveness`, `/health/readiness`, `/version` if missing. |
| WEB-CONTAINERS-45-001 `Helm readiness support` | TODO | BE-Base Platform Guild | HELM-45-001 | Ensure readiness endpoints reflect DB/queue readiness, add feature flag toggles via config map, and document NetworkPolicy ports. |
| WEB-CONTAINERS-46-001 `Air-gap hardening` | TODO | BE-Base Platform Guild | DEPLOY-AIRGAP-46-001 | Provide offline-friendly asset serving (no CDN), allow overriding object store endpoints via env, and document fallback behavior. |
## Authority-Backed Scopes & Tenancy (Epic 14)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-TEN-47-001 `Auth middleware` | TODO | BE-Base Platform Guild | AUTH-TEN-47-001 | Implement JWT verification, tenant activation from headers, scope matching, and decision audit emission for all API endpoints. |
| WEB-TEN-48-001 `Tenant context propagation` | TODO | BE-Base Platform Guild | WEB-TEN-47-001 | Set DB session `stella.tenant_id`, enforce tenant/project checks on persistence, prefix object storage paths, and stamp audit metadata. |
| WEB-TEN-49-001 `ABAC & audit API` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-TEN-48-001 | Integrate optional ABAC overlay with Policy Engine, expose `/audit/decisions` API, and support service token minting endpoints. |
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-OBS-50-001 `Telemetry core adoption` | TODO | BE-Base Platform Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Integrate `StellaOps.Telemetry.Core` into gateway host, replace ad-hoc logging, ensure all routes emit trace/span IDs, tenant context, and scrubbed payload previews. |
| WEB-OBS-51-001 `Observability health endpoints` | TODO | BE-Base Platform Guild | WEB-OBS-50-001, TELEMETRY-OBS-51-001 | Implement `/obs/health` and `/obs/slo` aggregations, pulling metrics from Prometheus/collector APIs, including burn-rate signals and exemplar links for Console widgets. |
| WEB-OBS-52-001 `Trace & log proxies` | TODO | BE-Base Platform Guild | WEB-OBS-50-001, TIMELINE-OBS-52-003 | Deliver `/obs/trace/:id` and `/obs/logs` proxy endpoints with guardrails (time window limits, tenant scoping) forwarding to timeline indexer + log store with signed URLs. |
| WEB-OBS-54-001 `Evidence & attestation bridges` | TODO | BE-Base Platform Guild | EVID-OBS-54-001, PROV-OBS-54-001 | Provide `/evidence/*` and `/attestations/*` pass-through endpoints, enforce `timeline:read`, `evidence:read`, `attest:read` scopes, append provenance headers, and surface verification summaries. |
| WEB-OBS-55-001 `Incident mode controls` | TODO | BE-Base Platform Guild, Ops Guild | WEB-OBS-50-001, TELEMETRY-OBS-55-001, DEVOPS-OBS-55-001 | Add `/obs/incident-mode` API (enable/disable/status) with audit trail, sampling override, retention bump preview, and CLI/Console hooks. |
| WEB-OBS-56-001 `Sealed status surfaces` | TODO | BE-Base Platform Guild, AirGap Guild | WEB-OBS-50-001, AIRGAP-CTL-56-002 | Extend telemetry core integration to expose sealed/unsealed status APIs, drift metrics, and Console widgets without leaking sealed-mode secrets. |
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-OAS-61-001 `Discovery endpoint` | TODO | BE-Base Platform Guild | OAS-61-002 | Implement `GET /.well-known/openapi` returning gateway spec with version metadata, cache headers, and signed ETag. |
| WEB-OAS-61-002 `Standard error envelope` | TODO | BE-Base Platform Guild | APIGOV-61-001 | Migrate gateway errors to standard envelope and update examples; ensure telemetry logs include `error.code`. |
| WEB-OAS-62-001 `Pagination & idempotency alignment` | TODO | BE-Base Platform Guild | WEB-OAS-61-002 | Normalize all endpoints to cursor pagination, expose `Idempotency-Key` support, and document rate-limit headers. |
| WEB-OAS-63-001 `Deprecation support` | TODO | BE-Base Platform Guild, API Governance Guild | APIGOV-63-001 | Add deprecation header middleware, Sunset link emission, and observability metrics for deprecated routes. |
## Risk Profiles (Epic 18)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| WEB-RISK-66-001 `Risk API routing` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-RISK-67-002 | Expose risk profile/results endpoints through gateway with tenant scoping, pagination, and rate limiting. |
| WEB-RISK-66-002 `Explainability downloads` | TODO | BE-Base Platform Guild, Risk Engine Guild | RISK-ENGINE-68-002 | Add signed URL handling for explanation blobs and enforce scope checks. |
| WEB-RISK-67-001 `Risk status endpoint` | TODO | BE-Base Platform Guild | WEB-RISK-66-001 | Provide aggregated risk stats (`/risk/status`) for Console dashboards (counts per severity, last computation). |
| WEB-RISK-68-001 `Notification hooks` | TODO | BE-Base Platform Guild, Notifications Guild | NOTIFY-RISK-66-001 | Emit events on severity transitions via gateway to notifier bus with trace metadata. |

View File

@@ -0,0 +1,112 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"stellaops-web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/stellaops-web",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "stellaops-web:build:production"
},
"development": {
"buildTarget": "stellaops-web:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "stellaops-web:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.cjs",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
# Deterministic Install & Headless Chromium
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
## Prerequisites
- Node.js **20.11.0** or newer (matches the `engines` constraint).
- npm **10.2.0** or newer.
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
## One-Time Cache Priming (Connected Host)
```bash
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
npm run ci:install
```
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
If you plan to bundle a Chromium binary, download it while still connected:
```bash
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
```
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
## Offline Runner Execution
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
3. Run `npm run ci:install` to restore dependencies without network access.
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
## Chromium Options
- **System package** Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
- **Environment override** Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
- **Offline cache drop** Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.

View File

@@ -0,0 +1,37 @@
# WEB1.TRIVY-SETTINGS Backend Contract & UI Wiring Notes
## 1. Known backend surfaces
- `POST /jobs/export:trivy-db`
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
## 2. Clarifications needed from Concelier backend
| Topic | Questions to resolve | Suggested owner |
| --- | --- | --- |
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
## 3. Proposed Angular implementation (pending contract lock)
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
- **State & transport**:
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
- **UX flow**:
- Load settings on navigation; show inline info about current publish/bundle defaults.
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
## 4. Next steps
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.

View File

@@ -0,0 +1,63 @@
const { join } = require('path');
const { resolveChromeBinary } = require('./scripts/chrome-path');
const { env } = process;
const chromeBin = resolveChromeBinary(__dirname);
if (chromeBin) {
env.CHROME_BIN = chromeBin;
} else if (!env.CHROME_BIN) {
console.warn(
'[karma] Unable to locate a Chromium binary automatically. ' +
'Set CHROME_BIN or STELLAOPS_CHROMIUM_BIN, or place an offline build under .cache/chromium/. ' +
'See docs/DeterministicInstall.md for bootstrap instructions.'
);
}
const isCI = env.CI === 'true' || env.CI === '1';
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false
},
jasmineHtmlReporter: {
suppressAll: true
},
coverageReporter: {
dir: join(__dirname, './coverage/stellaops-web'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
browsers: ['ChromeHeadlessOffline'],
customLaunchers: {
ChromeHeadlessOffline: {
base: 'ChromeHeadless',
flags: [
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox'
]
}
},
restartOnFileChange: false
});
};

13696
src/Web/StellaOps.Web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "stellaops-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "npm run verify:chromium && ng test --watch=false",
"test:watch": "ng test --watch",
"test:ci": "npm run test",
"test:e2e": "playwright test",
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
},
"engines": {
"node": ">=20.11.0",
"npm": ">=10.2.0"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@playwright/test": "^1.47.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.4.2"
}
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
const port = process.env.PLAYWRIGHT_PORT
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
: 4400;
export default defineConfig({
testDir: 'tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run serve:test',
reuseExistingServer: !process.env.CI,
url: `http://127.0.0.1:${port}`,
stdout: 'ignore',
stderr: 'ignore',
},
});

View File

@@ -0,0 +1,133 @@
const { existsSync, readdirSync, statSync } = require('fs');
const { join } = require('path');
const linuxArchivePath = ['.cache', 'chromium', 'chrome-linux64', 'chrome'];
const windowsArchivePath = ['.cache', 'chromium', 'chrome-win64', 'chrome.exe'];
const macArchivePath = [
'.cache',
'chromium',
'chrome-mac',
'Chromium.app',
'Contents',
'MacOS',
'Chromium'
];
function expandVersionedArchives(rootDir = join(__dirname, '..')) {
const base = join(rootDir, '.cache', 'chromium');
if (!existsSync(base)) {
return [];
}
const nestedCandidates = [];
for (const entry of readdirSync(base)) {
const nestedRoot = join(base, entry);
try {
if (!statSync(nestedRoot).isDirectory()) {
continue;
}
} catch {
continue;
}
nestedCandidates.push(
join(nestedRoot, 'chrome-linux64', 'chrome'),
join(nestedRoot, 'chrome-win64', 'chrome.exe'),
join(nestedRoot, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
);
}
return nestedCandidates;
}
function expandNestedArchives(rootDir = join(__dirname, '..')) {
const base = join(rootDir, '.cache', 'chromium');
if (!existsSync(base)) {
return [];
}
const maxDepth = 4;
const queue = [{ dir: base, depth: 0 }];
const candidates = [];
while (queue.length) {
const { dir, depth } = queue.shift();
let entries;
try {
entries = readdirSync(dir);
} catch {
continue;
}
for (const entry of entries) {
const nested = join(dir, entry);
let stats;
try {
stats = statSync(nested);
} catch {
continue;
}
if (!stats.isDirectory()) {
continue;
}
candidates.push(
join(nested, 'chrome-linux64', 'chrome'),
join(nested, 'chrome-win64', 'chrome.exe'),
join(nested, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
);
if (depth + 1 <= maxDepth) {
queue.push({ dir: nested, depth: depth + 1 });
}
}
}
return candidates;
}
function candidatePaths(rootDir = join(__dirname, '..')) {
const { env } = process;
const baseCandidates = [
env.STELLAOPS_CHROMIUM_BIN,
env.CHROME_BIN,
env.PUPPETEER_EXECUTABLE_PATH,
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
join(rootDir, ...linuxArchivePath),
join(rootDir, ...windowsArchivePath),
join(rootDir, ...macArchivePath),
...expandVersionedArchives(rootDir),
...expandNestedArchives(rootDir)
];
const seen = new Set();
return baseCandidates
.filter(Boolean)
.filter((candidate) => {
if (seen.has(candidate)) {
return false;
}
seen.add(candidate);
return true;
});
}
function resolveChromeBinary(rootDir = join(__dirname, '..')) {
for (const candidate of candidatePaths(rootDir)) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
module.exports = {
candidatePaths,
resolveChromeBinary
};

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env node
const { resolveChromeBinary, candidatePaths } = require('./chrome-path');
const { join } = require('path');
const projectRoot = join(__dirname, '..');
const resolved = resolveChromeBinary(projectRoot);
if (resolved) {
console.log(`Chromium binary detected: ${resolved}`);
process.exit(0);
}
console.error('Chromium binary not found.');
console.error('Checked locations:');
for (const candidate of candidatePaths(projectRoot)) {
console.error(` - ${candidate}`);
}
console.error('');
console.error(
'Ensure Google Chrome/Chromium is available on PATH, set CHROME_BIN/STELLAOPS_CHROMIUM_BIN, or drop an offline Chromium build under .cache/chromium/.'
);
console.error('See docs/DeterministicInstall.md for detailed guidance.');
process.exit(1);

View File

@@ -0,0 +1,46 @@
<div class="app-shell">
<header class="app-header">
<div class="app-brand">StellaOps Dashboard</div>
<nav class="app-nav">
<a routerLink="/console/profile" routerLinkActive="active">
Console Profile
</a>
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
Trivy DB Export
</a>
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
Scan Detail
</a>
<a routerLink="/notify" routerLinkActive="active">
Notify
</a>
</nav>
<div class="app-auth">
<ng-container *ngIf="isAuthenticated(); else signIn">
<span class="app-user" aria-live="polite">{{ displayName() }}</span>
<span class="app-tenant" *ngIf="activeTenant() as tenant">
Tenant: <strong>{{ tenant }}</strong>
</span>
<span
class="app-fresh"
*ngIf="freshAuthSummary() as fresh"
[class.app-fresh--active]="fresh.active"
[class.app-fresh--stale]="!fresh.active"
>
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
<ng-container *ngIf="fresh.expiresAt">
(expires {{ fresh.expiresAt | date: 'shortTime' }})
</ng-container>
</span>
<button type="button" (click)="onSignOut()">Sign out</button>
</ng-container>
<ng-template #signIn>
<button type="button" (click)="onSignIn()">Sign in</button>
</ng-template>
</div>
</header>
<main class="app-content">
<router-outlet />
</main>
</div>

View File

@@ -0,0 +1,112 @@
:host {
display: block;
min-height: 100vh;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
color: #0f172a;
background-color: #f8fafc;
}
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(90deg, #0f172a 0%, #1e293b 45%, #4328b7 100%);
color: #f8fafc;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2);
}
.app-brand {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.app-nav {
display: flex;
gap: 1rem;
a {
color: rgba(248, 250, 252, 0.8);
text-decoration: none;
font-size: 0.95rem;
padding: 0.35rem 0.75rem;
border-radius: 9999px;
transition: background-color 0.2s ease, color 0.2s ease;
&.active,
&:hover,
&:focus-visible {
color: #0f172a;
background-color: rgba(248, 250, 252, 0.9);
}
}
}
.app-auth {
display: flex;
align-items: center;
gap: 0.75rem;
.app-user {
font-size: 0.9rem;
font-weight: 500;
}
button {
appearance: none;
border: none;
border-radius: 9999px;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
color: #0f172a;
background-color: rgba(248, 250, 252, 0.9);
transition: transform 0.2s ease, background-color 0.2s ease;
&:hover,
&:focus-visible {
background-color: #facc15;
transform: translateY(-1px);
}
}
.app-tenant {
font-size: 0.8rem;
color: rgba(248, 250, 252, 0.8);
}
.app-fresh {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
background-color: rgba(20, 184, 166, 0.16);
color: #0f766e;
&.app-fresh--stale {
background-color: rgba(249, 115, 22, 0.16);
color: #c2410c;
}
}
}
.app-content {
flex: 1;
padding: 2rem 1.5rem;
max-width: 960px;
width: 100%;
margin: 0 auto;
}

View File

@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
logout = jasmine.createSpy('logout');
}
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent, RouterTestingModule],
providers: [
AuthSessionStore,
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
],
}).compileComponents();
});
it('creates the root component', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('renders a router outlet for child routes', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});

View File

@@ -0,0 +1,64 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { ConsoleSessionStore } from './core/console/console-session.store';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
readonly status = this.sessionStore.status;
readonly identity = this.sessionStore.identity;
readonly subjectHint = this.sessionStore.subjectHint;
readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly activeTenant = this.consoleStore.selectedTenantId;
readonly freshAuthSummary = computed(() => {
const token = this.consoleStore.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
readonly displayName = computed(() => {
const identity = this.identity();
if (identity?.name) {
return identity.name;
}
if (identity?.email) {
return identity.email;
}
const hint = this.subjectHint();
return hint ?? 'anonymous';
});
onSignIn(): void {
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
void this.auth.beginLogin(returnUrl);
}
onSignOut(): void {
void this.auth.logout();
}
}

View File

@@ -0,0 +1,81 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
import {
AUTHORITY_CONSOLE_API,
AUTHORITY_CONSOLE_API_BASE_URL,
AuthorityConsoleApiHttpClient,
} from './core/api/authority-console.client';
import {
NOTIFY_API,
NOTIFY_API_BASE_URL,
NOTIFY_TENANT_ID,
} from './core/api/notify.client';
import { AppConfigService } from './core/config/app-config.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { MockNotifyApiService } from './testing/mock-notify-api.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
{
provide: APP_INITIALIZER,
multi: true,
useFactory: (configService: AppConfigService) => () =>
configService.load(),
deps: [AppConfigService],
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthHttpInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: OperatorMetadataInterceptor,
multi: true,
},
{
provide: CONCELIER_EXPORTER_API_BASE_URL,
useValue: '/api/v1/concelier/exporters/trivy-db',
},
{
provide: AUTHORITY_CONSOLE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/console', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/console`;
}
},
},
AuthorityConsoleApiHttpClient,
{
provide: AUTHORITY_CONSOLE_API,
useExisting: AuthorityConsoleApiHttpClient,
},
{
provide: NOTIFY_API_BASE_URL,
useValue: '/api/v1/notify',
},
{
provide: NOTIFY_TENANT_ID,
useValue: 'tenant-dev',
},
MockNotifyApiService,
{
provide: NOTIFY_API,
useExisting: MockNotifyApiService,
},
],
};

View File

@@ -0,0 +1,48 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'console/profile',
loadComponent: () =>
import('./features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',
redirectTo: 'console/profile',
},
{
path: '**',
redirectTo: 'console/profile',
},
];

View File

@@ -0,0 +1,113 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
export interface AuthorityTenantViewDto {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface TenantCatalogResponseDto {
readonly tenants: readonly AuthorityTenantViewDto[];
}
export interface ConsoleProfileDto {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenIntrospectionDto {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: string | null;
readonly authenticationTime: string | null;
readonly expiresAt: string | null;
readonly freshAuth: boolean;
}
export interface AuthorityConsoleApi {
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto>;
}
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
'AUTHORITY_CONSOLE_API'
);
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
'AUTHORITY_CONSOLE_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
constructor(
private readonly http: HttpClient,
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore
) {}
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(tenantId),
});
}
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
headers: this.buildHeaders(tenantId),
});
}
introspectToken(
tenantId?: string
): Observable<ConsoleTokenIntrospectionDto> {
return this.http.post<ConsoleTokenIntrospectionDto>(
`${this.baseUrl}/token/introspect`,
{},
{
headers: this.buildHeaders(tenantId),
}
);
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
this.authSession.getActiveTenantId();
if (!tenantId) {
throw new Error(
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
);
}
return new HttpHeaders({
'X-StellaOps-Tenant': tenantId,
});
}
}

View File

@@ -0,0 +1,51 @@
import { HttpClient } from '@angular/common/http';
import {
Injectable,
InjectionToken,
inject,
} from '@angular/core';
import { Observable } from 'rxjs';
export interface TrivyDbSettingsDto {
publishFull: boolean;
publishDelta: boolean;
includeFull: boolean;
includeDelta: boolean;
}
export interface TrivyDbRunResponseDto {
exportId: string;
triggeredAt: string;
status?: string;
}
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
'CONCELIER_EXPORTER_API_BASE_URL'
);
@Injectable({
providedIn: 'root',
})
export class ConcelierExporterClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
}
updateTrivyDbSettings(
settings: TrivyDbSettingsDto
): Observable<TrivyDbSettingsDto> {
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
}
runTrivyDbExport(
settings: TrivyDbSettingsDto
): Observable<TrivyDbRunResponseDto> {
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
trigger: 'ui',
parameters: settings,
});
}
}

View File

@@ -0,0 +1,142 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import {
Inject,
Injectable,
InjectionToken,
Optional,
} from '@angular/core';
import { Observable } from 'rxjs';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
ChannelTestSendResponse,
NotifyChannel,
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyRule,
} from './notify.models';
export interface NotifyApi {
listChannels(): Observable<NotifyChannel[]>;
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
deleteChannel(channelId: string): Observable<void>;
getChannelHealth(channelId: string): Observable<ChannelHealthResponse>;
testChannel(
channelId: string,
payload: ChannelTestSendRequest
): Observable<ChannelTestSendResponse>;
listRules(): Observable<NotifyRule[]>;
saveRule(rule: NotifyRule): Observable<NotifyRule>;
deleteRule(ruleId: string): Observable<void>;
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse>;
}
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
'NOTIFY_API_BASE_URL'
);
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
@Injectable({ providedIn: 'root' })
export class NotifyApiHttpClient implements NotifyApi {
constructor(
private readonly http: HttpClient,
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
) {}
listChannels(): Observable<NotifyChannel[]> {
return this.http.get<NotifyChannel[]>(`${this.baseUrl}/channels`, {
headers: this.buildHeaders(),
});
}
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
return this.http.post<NotifyChannel>(`${this.baseUrl}/channels`, channel, {
headers: this.buildHeaders(),
});
}
deleteChannel(channelId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/channels/${channelId}`, {
headers: this.buildHeaders(),
});
}
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
return this.http.get<ChannelHealthResponse>(
`${this.baseUrl}/channels/${channelId}/health`,
{
headers: this.buildHeaders(),
}
);
}
testChannel(
channelId: string,
payload: ChannelTestSendRequest
): Observable<ChannelTestSendResponse> {
return this.http.post<ChannelTestSendResponse>(
`${this.baseUrl}/channels/${channelId}/test`,
payload,
{
headers: this.buildHeaders(),
}
);
}
listRules(): Observable<NotifyRule[]> {
return this.http.get<NotifyRule[]>(`${this.baseUrl}/rules`, {
headers: this.buildHeaders(),
});
}
saveRule(rule: NotifyRule): Observable<NotifyRule> {
return this.http.post<NotifyRule>(`${this.baseUrl}/rules`, rule, {
headers: this.buildHeaders(),
});
}
deleteRule(ruleId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/rules/${ruleId}`, {
headers: this.buildHeaders(),
});
}
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse> {
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.since) {
params = params.set('since', options.since);
}
if (options?.limit) {
params = params.set('limit', options.limit);
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<NotifyDeliveriesResponse>(`${this.baseUrl}/deliveries`, {
headers: this.buildHeaders(),
params,
});
}
private buildHeaders(): HttpHeaders {
if (!this.tenantId) {
return new HttpHeaders();
}
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
}
}

View File

@@ -0,0 +1,194 @@
export type NotifyChannelType =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Custom';
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
export type NotifyDeliveryStatus =
| 'Pending'
| 'Sent'
| 'Failed'
| 'Throttled'
| 'Digested'
| 'Dropped';
export type NotifyDeliveryAttemptStatus =
| 'Enqueued'
| 'Sending'
| 'Succeeded'
| 'Failed'
| 'Throttled'
| 'Skipped';
export type NotifyDeliveryFormat =
| 'Slack'
| 'Teams'
| 'Email'
| 'Webhook'
| 'Json';
export interface NotifyChannelLimits {
readonly concurrency?: number | null;
readonly requestsPerMinute?: number | null;
readonly timeout?: string | null;
readonly maxBatchSize?: number | null;
}
export interface NotifyChannelConfig {
readonly secretRef: string;
readonly target?: string;
readonly endpoint?: string;
readonly properties?: Record<string, string>;
readonly limits?: NotifyChannelLimits | null;
}
export interface NotifyChannel {
readonly schemaVersion?: string;
readonly channelId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly type: NotifyChannelType;
readonly enabled: boolean;
readonly config: NotifyChannelConfig;
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyRuleMatchVex {
readonly includeAcceptedJustifications?: boolean;
readonly includeRejectedJustifications?: boolean;
readonly includeUnknownJustifications?: boolean;
readonly justificationKinds?: readonly string[];
}
export interface NotifyRuleMatch {
readonly eventKinds?: readonly string[];
readonly namespaces?: readonly string[];
readonly repositories?: readonly string[];
readonly digests?: readonly string[];
readonly labels?: readonly string[];
readonly componentPurls?: readonly string[];
readonly minSeverity?: string | null;
readonly verdicts?: readonly string[];
readonly kevOnly?: boolean | null;
readonly vex?: NotifyRuleMatchVex | null;
}
export interface NotifyRuleAction {
readonly actionId: string;
readonly channel: string;
readonly template?: string;
readonly digest?: string;
readonly throttle?: string | null;
readonly locale?: string;
readonly enabled: boolean;
readonly metadata?: Record<string, string>;
}
export interface NotifyRule {
readonly schemaVersion?: string;
readonly ruleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly enabled: boolean;
readonly match: NotifyRuleMatch;
readonly actions: readonly NotifyRuleAction[];
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy?: string;
readonly createdAt?: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface NotifyDeliveryAttempt {
readonly timestamp: string;
readonly status: NotifyDeliveryAttemptStatus;
readonly statusCode?: number;
readonly reason?: string;
}
export interface NotifyDeliveryRendered {
readonly channelType: NotifyChannelType;
readonly format: NotifyDeliveryFormat;
readonly target: string;
readonly title: string;
readonly body: string;
readonly summary?: string;
readonly textBody?: string;
readonly locale?: string;
readonly bodyHash?: string;
readonly attachments?: readonly string[];
}
export interface NotifyDelivery {
readonly deliveryId: string;
readonly tenantId: string;
readonly ruleId: string;
readonly actionId: string;
readonly eventId: string;
readonly kind: string;
readonly status: NotifyDeliveryStatus;
readonly statusReason?: string;
readonly rendered?: NotifyDeliveryRendered;
readonly attempts?: readonly NotifyDeliveryAttempt[];
readonly metadata?: Record<string, string>;
readonly createdAt: string;
readonly sentAt?: string;
readonly completedAt?: string;
}
export interface NotifyDeliveriesQueryOptions {
readonly status?: NotifyDeliveryStatus;
readonly since?: string;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface NotifyDeliveriesResponse {
readonly items: readonly NotifyDelivery[];
readonly continuationToken?: string | null;
readonly count: number;
}
export interface ChannelHealthResponse {
readonly tenantId: string;
readonly channelId: string;
readonly status: ChannelHealthStatus;
readonly message?: string | null;
readonly checkedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}
export interface ChannelTestSendRequest {
readonly target?: string;
readonly templateId?: string;
readonly title?: string;
readonly summary?: string;
readonly body?: string;
readonly textBody?: string;
readonly locale?: string;
readonly metadata?: Record<string, string>;
readonly attachments?: readonly string[];
}
export interface ChannelTestSendResponse {
readonly tenantId: string;
readonly channelId: string;
readonly preview: NotifyDeliveryRendered;
readonly queuedAt: string;
readonly traceId: string;
readonly metadata?: Record<string, string>;
}

View File

@@ -0,0 +1,128 @@
export interface PolicyPreviewRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
policy?: PolicyPreviewPolicyDto;
}
export interface PolicyPreviewPolicyDto {
content?: string;
format?: string;
actor?: string;
description?: string;
}
export interface PolicyPreviewFindingDto {
id: string;
severity: string;
environment?: string;
source?: string;
vendor?: string;
license?: string;
image?: string;
repository?: string;
package?: string;
purl?: string;
cve?: string;
path?: string;
layerDigest?: string;
tags?: ReadonlyArray<string>;
}
export interface PolicyPreviewVerdictDto {
findingId: string;
status: string;
ruleName?: string | null;
ruleAction?: string | null;
notes?: string | null;
score?: number | null;
configVersion?: string | null;
inputs?: Readonly<Record<string, number>>;
quietedBy?: string | null;
quiet?: boolean | null;
unknownConfidence?: number | null;
confidenceBand?: string | null;
unknownAgeDays?: number | null;
sourceTrust?: string | null;
reachability?: string | null;
}
export interface PolicyPreviewDiffDto {
findingId: string;
baseline: PolicyPreviewVerdictDto;
projected: PolicyPreviewVerdictDto;
changed: boolean;
}
export interface PolicyPreviewIssueDto {
code: string;
message: string;
severity: string;
path: string;
}
export interface PolicyPreviewResponseDto {
success: boolean;
policyDigest: string;
revisionId?: string | null;
changed: number;
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyPreviewSample {
previewRequest: PolicyPreviewRequestDto;
previewResponse: PolicyPreviewResponseDto;
}
export interface PolicyReportRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
}
export interface PolicyReportResponseDto {
report: PolicyReportDocumentDto;
dsse?: DsseEnvelopeDto | null;
}
export interface PolicyReportDocumentDto {
reportId: string;
imageDigest: string;
generatedAt: string;
verdict: string;
policy: PolicyReportPolicyDto;
summary: PolicyReportSummaryDto;
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyReportPolicyDto {
revisionId?: string | null;
digest?: string | null;
}
export interface PolicyReportSummaryDto {
total: number;
blocked: number;
warned: number;
ignored: number;
quieted: number;
}
export interface DsseEnvelopeDto {
payloadType: string;
payload: string;
signatures: ReadonlyArray<DsseSignatureDto>;
}
export interface DsseSignatureDto {
keyId: string;
algorithm: string;
signature: string;
}
export interface PolicyReportSample {
reportRequest: PolicyReportRequestDto;
reportResponse: PolicyReportResponseDto;
}

View File

@@ -0,0 +1,17 @@
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
export interface ScanAttestationStatus {
readonly uuid: string;
readonly status: ScanAttestationStatusKind;
readonly index?: number;
readonly logUrl?: string;
readonly checkedAt?: string;
readonly statusMessage?: string;
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
readonly completedAt: string;
readonly attestation?: ScanAttestationStatus;
}

View File

@@ -0,0 +1,171 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import { DpopService } from './dpop/dpop.service';
import { AuthorityAuthService } from './authority-auth.service';
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
private excludedOrigins: Set<string> | null = null;
private tokenEndpoint: string | null = null;
private authorityResolved = false;
constructor(
private readonly auth: AuthorityAuthService,
private readonly config: AppConfigService,
private readonly dpop: DpopService
) {
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
this.ensureAuthorityInfo();
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
return next.handle(request);
}
return from(
this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
)
).pipe(
switchMap((headers) => {
if (!headers) {
return next.handle(request);
}
const authorizedRequest = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '0'),
});
return next.handle(authorizedRequest);
}),
catchError((error: HttpErrorResponse) =>
this.handleError(request, error, next)
)
);
}
private handleError(
request: HttpRequest<unknown>,
error: HttpErrorResponse,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (error.status !== 401) {
return throwError(() => error);
}
const nonce = error.headers?.get('DPoP-Nonce');
if (!nonce) {
return throwError(() => error);
}
if (request.headers.get(RETRY_HEADER) === '1') {
return throwError(() => error);
}
return from(this.retryWithNonce(request, nonce, next)).pipe(
catchError(() => throwError(() => error))
);
}
private async retryWithNonce(
request: HttpRequest<unknown>,
nonce: string,
next: HttpHandler
): Promise<HttpEvent<unknown>> {
await this.dpop.setNonce(nonce);
const headers = await this.auth.getAuthHeadersForRequest(
this.resolveAbsoluteUrl(request.url),
request.method
);
if (!headers) {
throw new Error('Unable to refresh authorization headers after nonce.');
}
const retried = request.clone({
setHeaders: {
Authorization: headers.authorization,
DPoP: headers.dpop,
},
headers: request.headers.set(RETRY_HEADER, '1'),
});
return firstValueFrom(next.handle(retried));
}
private shouldSkip(url: string): boolean {
this.ensureAuthorityInfo();
const absolute = this.resolveAbsoluteUrl(url);
if (!absolute) {
return false;
}
try {
const resolved = new URL(absolute);
if (resolved.pathname.endsWith('/config.json')) {
return true;
}
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
return true;
}
const origin = resolved.origin;
return this.excludedOrigins?.has(origin) ?? false;
} catch {
return false;
}
}
private resolveAbsoluteUrl(url: string): string {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
return base ? new URL(url, base).toString() : url;
} catch {
return url;
}
}
private ensureAuthorityInfo(): void {
if (this.authorityResolved) {
return;
}
try {
const authority = this.config.authority;
this.tokenEndpoint = new URL(
authority.tokenEndpoint,
authority.issuer
).toString();
this.excludedOrigins = new Set<string>([
this.tokenEndpoint,
new URL(authority.authorizeEndpoint, authority.issuer).origin,
]);
this.authorityResolved = true;
} catch {
// Configuration not yet loaded; interceptor will retry on the next request.
}
}
}

View File

@@ -0,0 +1,56 @@
export interface AuthTokens {
readonly accessToken: string;
readonly expiresAtEpochMs: number;
readonly refreshToken?: string;
readonly tokenType: 'Bearer';
readonly scope: string;
}
export interface AuthIdentity {
readonly subject: string;
readonly name?: string;
readonly email?: string;
readonly roles: readonly string[];
readonly idToken?: string;
}
export interface AuthSession {
readonly tokens: AuthTokens;
readonly identity: AuthIdentity;
/**
* SHA-256 JWK thumbprint of the active DPoP key pair.
*/
readonly dpopKeyThumbprint: string;
readonly issuedAtEpochMs: number;
readonly tenantId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationTimeEpochMs: number | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAtEpochMs: number | null;
}
export interface PersistedSessionMetadata {
readonly subject: string;
readonly expiresAtEpochMs: number;
readonly issuedAtEpochMs: number;
readonly dpopKeyThumbprint: string;
readonly tenantId?: string | null;
}
export type AuthStatus =
| 'unauthenticated'
| 'authenticated'
| 'refreshing'
| 'loading';
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
export type AuthErrorReason =
| 'invalid_state'
| 'token_exchange_failed'
| 'refresh_failed'
| 'dpop_generation_failed'
| 'configuration_missing';

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
describe('AuthSessionStore', () => {
let store: AuthSessionStore;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [AuthSessionStore],
});
store = TestBed.inject(AuthSessionStore);
});
it('persists minimal metadata when session is set', () => {
const tokens: AuthTokens = {
accessToken: 'token-abc',
expiresAtEpochMs: Date.now() + 120_000,
refreshToken: 'refresh-xyz',
scope: 'openid ui.read',
tokenType: 'Bearer',
};
const session: AuthSession = {
tokens,
identity: {
subject: 'user-123',
name: 'Alex Operator',
roles: ['ui.read'],
},
dpopKeyThumbprint: 'thumbprint-1',
issuedAtEpochMs: Date.now(),
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
};
store.setSession(session);
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(persisted).toBeTruthy();
const parsed = JSON.parse(persisted ?? '{}');
expect(parsed.subject).toBe('user-123');
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
expect(parsed.tenantId).toBe('tenant-default');
store.clear();
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,129 @@
import { Injectable, computed, signal } from '@angular/core';
import {
AuthSession,
AuthStatus,
PersistedSessionMetadata,
SESSION_STORAGE_KEY,
} from './auth-session.model';
@Injectable({
providedIn: 'root',
})
export class AuthSessionStore {
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
private readonly persistedSignal =
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
readonly session = computed(() => this.sessionSignal());
readonly status = computed(() => this.statusSignal());
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
readonly subjectHint = computed(
() =>
this.sessionSignal()?.identity.subject ??
this.persistedSignal()?.subject ??
null
);
readonly expiresAtEpochMs = computed(
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
);
readonly isAuthenticated = computed(
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
);
readonly tenantId = computed(
() =>
this.sessionSignal()?.tenantId ??
this.persistedSignal()?.tenantId ??
null
);
setStatus(status: AuthStatus): void {
this.statusSignal.set(status);
}
setSession(session: AuthSession | null): void {
this.sessionSignal.set(session);
if (!session) {
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
return;
}
this.statusSignal.set('authenticated');
const metadata: PersistedSessionMetadata = {
subject: session.identity.subject,
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
issuedAtEpochMs: session.issuedAtEpochMs,
dpopKeyThumbprint: session.dpopKeyThumbprint,
tenantId: session.tenantId,
};
this.persistedSignal.set(metadata);
this.persistMetadata(metadata);
}
clear(): void {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.persistedSignal.set(null);
this.clearPersistedMetadata();
}
private readPersistedMetadata(): PersistedSessionMetadata | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
if (
typeof parsed.subject !== 'string' ||
typeof parsed.expiresAtEpochMs !== 'number' ||
typeof parsed.issuedAtEpochMs !== 'number' ||
typeof parsed.dpopKeyThumbprint !== 'string'
) {
return null;
}
const tenantId =
typeof parsed.tenantId === 'string'
? parsed.tenantId.trim() || null
: null;
return {
subject: parsed.subject,
expiresAtEpochMs: parsed.expiresAtEpochMs,
issuedAtEpochMs: parsed.issuedAtEpochMs,
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
tenantId,
};
} catch {
return null;
}
}
private persistMetadata(metadata: PersistedSessionMetadata): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
}
private clearPersistedMetadata(): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
getActiveTenantId(): string | null {
return this.tenantId();
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
export interface PendingLoginRequest {
readonly state: string;
readonly codeVerifier: string;
readonly createdAtEpochMs: number;
readonly returnUrl?: string;
readonly nonce?: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthStorageService {
savePendingLogin(request: PendingLoginRequest): void {
if (typeof sessionStorage === 'undefined') {
return;
}
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
}
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
if (typeof sessionStorage === 'undefined') {
return null;
}
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
try {
const request = JSON.parse(raw) as PendingLoginRequest;
if (request.state !== expectedState) {
return null;
}
return request;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,622 @@
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AppConfigService } from '../config/app-config.service';
import { AuthorityConfig } from '../config/app-config.model';
import { ConsoleSessionService } from '../console/console-session.service';
import {
ACCESS_TOKEN_REFRESH_THRESHOLD_MS,
AuthErrorReason,
AuthSession,
AuthTokens,
} from './auth-session.model';
import { AuthSessionStore } from './auth-session.store';
import {
AuthStorageService,
PendingLoginRequest,
} from './auth-storage.service';
import { DpopService } from './dpop/dpop.service';
import { base64UrlDecode } from './dpop/jose-utilities';
import { createPkcePair } from './pkce.util';
interface TokenResponse {
readonly access_token: string;
readonly token_type: string;
readonly expires_in: number;
readonly scope?: string;
readonly refresh_token?: string;
readonly id_token?: string;
}
interface RefreshTokenResponse extends TokenResponse {}
export interface AuthorizationHeaders {
readonly authorization: string;
readonly dpop: string;
}
export interface CompleteLoginResult {
readonly returnUrl?: string;
}
const TOKEN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
interface AccessTokenMetadata {
tenantId: string | null;
scopes: string[];
audiences: string[];
authenticationTimeEpochMs: number | null;
freshAuthActive: boolean;
freshAuthExpiresAtEpochMs: number | null;
}
@Injectable({
providedIn: 'root',
})
export class AuthorityAuthService {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshInFlight: Promise<void> | null = null;
private lastError: AuthErrorReason | null = null;
constructor(
private readonly http: HttpClient,
private readonly config: AppConfigService,
private readonly sessionStore: AuthSessionStore,
private readonly storage: AuthStorageService,
private readonly dpop: DpopService,
private readonly consoleSession: ConsoleSessionService
) {}
get error(): AuthErrorReason | null {
return this.lastError;
}
async beginLogin(returnUrl?: string): Promise<void> {
const authority = this.config.authority;
const pkce = await createPkcePair();
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
const nonce = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
// Generate the DPoP key pair up-front so the same key is bound to the token.
await this.dpop.getThumbprint();
const authorizeUrl = this.buildAuthorizeUrl(authority, {
state,
nonce,
codeChallenge: pkce.challenge,
codeChallengeMethod: pkce.method,
returnUrl,
});
const now = Date.now();
this.storage.savePendingLogin({
state,
codeVerifier: pkce.verifier,
createdAtEpochMs: now,
returnUrl,
nonce,
});
window.location.assign(authorizeUrl);
}
/**
* Completes the authorization code flow after the Authority redirects back with ?code & ?state.
*/
async completeLoginFromRedirect(
queryParams: URLSearchParams
): Promise<CompleteLoginResult> {
const code = queryParams.get('code');
const state = queryParams.get('state');
if (!code || !state) {
throw new Error('Missing authorization code or state.');
}
const pending = this.storage.consumePendingLogin(state);
if (!pending) {
this.lastError = 'invalid_state';
throw new Error('State parameter did not match pending login request.');
}
try {
const tokenResponse = await this.exchangeCodeForTokens(
code,
pending.codeVerifier
);
await this.onTokenResponse(tokenResponse, pending.nonce ?? null);
this.lastError = null;
return { returnUrl: pending.returnUrl };
} catch (error) {
this.lastError = 'token_exchange_failed';
this.sessionStore.clear();
this.consoleSession.clear();
throw error;
}
}
async ensureValidAccessToken(): Promise<string | null> {
const session = this.sessionStore.session();
if (!session) {
return null;
}
const now = Date.now();
if (now < session.tokens.expiresAtEpochMs - ACCESS_TOKEN_REFRESH_THRESHOLD_MS) {
return session.tokens.accessToken;
}
await this.refreshAccessToken();
const refreshed = this.sessionStore.session();
return refreshed?.tokens.accessToken ?? null;
}
async getAuthHeadersForRequest(
url: string,
method: string
): Promise<AuthorizationHeaders | null> {
const accessToken = await this.ensureValidAccessToken();
if (!accessToken) {
return null;
}
const dpopProof = await this.dpop.createProof({
htm: method,
htu: url,
accessToken,
});
return {
authorization: `DPoP ${accessToken}`,
dpop: dpopProof,
};
}
async refreshAccessToken(): Promise<void> {
const session = this.sessionStore.session();
const refreshToken = session?.tokens.refreshToken;
if (!refreshToken) {
return;
}
if (this.refreshInFlight) {
await this.refreshInFlight;
return;
}
this.refreshInFlight = this.executeRefresh(refreshToken)
.catch((error) => {
this.lastError = 'refresh_failed';
this.sessionStore.clear();
this.consoleSession.clear();
throw error;
})
.finally(() => {
this.refreshInFlight = null;
});
await this.refreshInFlight;
}
async logout(): Promise<void> {
const session = this.sessionStore.session();
this.cancelRefreshTimer();
this.sessionStore.clear();
this.consoleSession.clear();
await this.dpop.setNonce(null);
const authority = this.config.authority;
if (!authority.logoutEndpoint) {
return;
}
if (session?.identity.idToken) {
const url = new URL(authority.logoutEndpoint, authority.issuer);
url.searchParams.set('post_logout_redirect_uri', authority.postLogoutRedirectUri ?? authority.redirectUri);
url.searchParams.set('id_token_hint', session.identity.idToken);
window.location.assign(url.toString());
} else {
window.location.assign(authority.postLogoutRedirectUri ?? authority.redirectUri);
}
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<HttpResponse<TokenResponse>> {
const authority = this.config.authority;
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('redirect_uri', authority.redirectUri);
body.set('client_id', authority.clientId);
body.set('code_verifier', codeVerifier);
if (authority.audience) {
body.set('audience', authority.audience);
}
const dpopProof = await this.dpop.createProof({
htm: 'POST',
htu: tokenUrl,
});
const headers = new HttpHeaders({
'Content-Type': TOKEN_CONTENT_TYPE,
DPoP: dpopProof,
});
return firstValueFrom(
this.http.post<TokenResponse>(tokenUrl, body.toString(), {
headers,
withCredentials: true,
observe: 'response',
})
);
}
private async executeRefresh(refreshToken: string): Promise<void> {
const authority = this.config.authority;
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);
body.set('client_id', authority.clientId);
if (authority.audience) {
body.set('audience', authority.audience);
}
const proof = await this.dpop.createProof({
htm: 'POST',
htu: tokenUrl,
});
const headers = new HttpHeaders({
'Content-Type': TOKEN_CONTENT_TYPE,
DPoP: proof,
});
const response = await firstValueFrom(
this.http.post<RefreshTokenResponse>(tokenUrl, body.toString(), {
headers,
withCredentials: true,
observe: 'response',
})
);
await this.onTokenResponse(response, null);
}
private async onTokenResponse(
response: HttpResponse<TokenResponse>,
expectedNonce: string | null
): Promise<void> {
const nonce = response.headers.get('DPoP-Nonce');
if (nonce) {
await this.dpop.setNonce(nonce);
}
const payload = response.body;
if (!payload) {
throw new Error('Token response did not include a body.');
}
const tokens = this.toAuthTokens(payload);
const accessMetadata = this.parseAccessTokenMetadata(payload.access_token);
const identity = this.parseIdentity(payload.id_token ?? '', expectedNonce);
const thumbprint = await this.dpop.getThumbprint();
if (!thumbprint) {
throw new Error('DPoP thumbprint unavailable.');
}
const session: AuthSession = {
tokens,
identity,
dpopKeyThumbprint: thumbprint,
issuedAtEpochMs: Date.now(),
tenantId: accessMetadata.tenantId,
scopes: accessMetadata.scopes,
audiences: accessMetadata.audiences,
authenticationTimeEpochMs: accessMetadata.authenticationTimeEpochMs,
freshAuthActive: accessMetadata.freshAuthActive,
freshAuthExpiresAtEpochMs: accessMetadata.freshAuthExpiresAtEpochMs,
};
this.sessionStore.setSession(session);
void this.consoleSession.loadConsoleContext();
this.scheduleRefresh(tokens, this.config.authority);
}
private toAuthTokens(payload: TokenResponse): AuthTokens {
const expiresAtEpochMs = Date.now() + payload.expires_in * 1000;
return {
accessToken: payload.access_token,
tokenType: (payload.token_type ?? 'Bearer') as 'Bearer',
refreshToken: payload.refresh_token,
scope: payload.scope ?? '',
expiresAtEpochMs,
};
}
private parseIdentity(
idToken: string,
expectedNonce: string | null
): AuthSession['identity'] {
if (!idToken) {
return {
subject: 'unknown',
roles: [],
};
}
const claims = decodeJwt(idToken);
const nonceClaim = claims['nonce'];
if (
expectedNonce &&
typeof nonceClaim === 'string' &&
nonceClaim !== expectedNonce
) {
throw new Error('OIDC nonce mismatch.');
}
const subjectClaim = claims['sub'];
const nameClaim = claims['name'];
const emailClaim = claims['email'];
const rolesClaim = claims['role'];
return {
subject: typeof subjectClaim === 'string' ? subjectClaim : 'unknown',
name: typeof nameClaim === 'string' ? nameClaim : undefined,
email: typeof emailClaim === 'string' ? emailClaim : undefined,
roles: Array.isArray(rolesClaim)
? rolesClaim.filter((entry: unknown): entry is string =>
typeof entry === 'string'
)
: [],
idToken,
};
}
private scheduleRefresh(tokens: AuthTokens, authority: AuthorityConfig): void {
this.cancelRefreshTimer();
const leeway =
(authority.refreshLeewaySeconds ?? 60) * 1000 +
ACCESS_TOKEN_REFRESH_THRESHOLD_MS;
const now = Date.now();
const ttl = Math.max(tokens.expiresAtEpochMs - now - leeway, 5_000);
this.refreshTimer = setTimeout(() => {
void this.refreshAccessToken();
}, ttl);
}
private cancelRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
private parseAccessTokenMetadata(accessToken: string | undefined): AccessTokenMetadata {
if (!accessToken) {
return {
tenantId: null,
scopes: [],
audiences: [],
authenticationTimeEpochMs: null,
freshAuthActive: false,
freshAuthExpiresAtEpochMs: null,
};
}
const claims = decodeJwt(accessToken);
const tenantClaim = claims['stellaops:tenant'];
const tenantId =
typeof tenantClaim === 'string' && tenantClaim.trim().length > 0
? tenantClaim.trim()
: null;
const scopeSet = new Set<string>();
const scpClaim = claims['scp'];
if (Array.isArray(scpClaim)) {
for (const entry of scpClaim) {
if (typeof entry === 'string' && entry.trim().length > 0) {
scopeSet.add(entry.trim());
}
}
}
const scopeClaim = claims['scope'];
if (typeof scopeClaim === 'string') {
scopeClaim
.split(/\s+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.forEach((entry) => scopeSet.add(entry));
}
const audiences: string[] = [];
const audClaim = claims['aud'];
if (Array.isArray(audClaim)) {
for (const entry of audClaim) {
if (typeof entry === 'string' && entry.trim().length > 0) {
audiences.push(entry.trim());
}
}
} else if (typeof audClaim === 'string' && audClaim.trim().length > 0) {
audiences.push(audClaim.trim());
}
const authenticationTimeEpochMs = this.parseEpochSeconds(
claims['auth_time'] ?? claims['authentication_time']
);
const freshAuthActive = this.parseFreshAuthFlag(
claims['stellaops:fresh_auth'] ?? claims['fresh_auth']
);
const ttlMs = this.parseDurationToMilliseconds(
claims['stellaops:fresh_auth_ttl']
);
let freshAuthExpiresAtEpochMs: number | null = null;
if (authenticationTimeEpochMs !== null) {
if (ttlMs !== null) {
freshAuthExpiresAtEpochMs = authenticationTimeEpochMs + ttlMs;
} else if (freshAuthActive) {
freshAuthExpiresAtEpochMs = authenticationTimeEpochMs + 300_000;
}
}
return {
tenantId,
scopes: Array.from(scopeSet).sort(),
audiences: audiences.sort(),
authenticationTimeEpochMs,
freshAuthActive,
freshAuthExpiresAtEpochMs,
};
}
private parseFreshAuthFlag(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === '0') {
return false;
}
}
return false;
}
private parseDurationToMilliseconds(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, value * 1000);
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const seconds = Number(trimmed);
if (!Number.isFinite(seconds)) {
return null;
}
return Math.max(0, seconds * 1000);
}
const isoMatch =
/^P(T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)$/i.exec(trimmed);
if (isoMatch) {
const hours = isoMatch[2] ? Number(isoMatch[2]) : 0;
const minutes = isoMatch[3] ? Number(isoMatch[3]) : 0;
const seconds = isoMatch[4] ? Number(isoMatch[4]) : 0;
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
return Math.max(0, totalSeconds * 1000);
}
const spanMatch =
/^(-)?(?:(\d+)\.)?(\d{1,2}):([0-5]?\d):([0-5]?\d)(\.\d+)?$/.exec(trimmed);
if (spanMatch) {
const isNegative = !!spanMatch[1];
const days = spanMatch[2] ? Number(spanMatch[2]) : 0;
const hours = Number(spanMatch[3]);
const minutes = Number(spanMatch[4]);
const seconds =
Number(spanMatch[5]) + (spanMatch[6] ? Number(spanMatch[6]) : 0);
const totalSeconds =
days * 86400 + hours * 3600 + minutes * 60 + seconds;
if (!Number.isFinite(totalSeconds)) {
return null;
}
const ms = totalSeconds * 1000;
return isNegative ? 0 : Math.max(0, ms);
}
return null;
}
private parseEpochSeconds(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value * 1000;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const numeric = Number(trimmed);
if (!Number.isNaN(numeric) && Number.isFinite(numeric)) {
return numeric * 1000;
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return null;
}
private buildAuthorizeUrl(
authority: AuthorityConfig,
options: {
state: string;
nonce: string;
codeChallenge: string;
codeChallengeMethod: 'S256';
returnUrl?: string;
}
): string {
const authorizeUrl = new URL(
authority.authorizeEndpoint,
authority.issuer
);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', authority.clientId);
authorizeUrl.searchParams.set('redirect_uri', authority.redirectUri);
authorizeUrl.searchParams.set('scope', authority.scope);
authorizeUrl.searchParams.set('state', options.state);
authorizeUrl.searchParams.set('nonce', options.nonce);
authorizeUrl.searchParams.set('code_challenge', options.codeChallenge);
authorizeUrl.searchParams.set(
'code_challenge_method',
options.codeChallengeMethod
);
if (authority.audience) {
authorizeUrl.searchParams.set('audience', authority.audience);
}
if (options.returnUrl) {
authorizeUrl.searchParams.set('ui_return', options.returnUrl);
}
return authorizeUrl.toString();
}
}
function decodeJwt(token: string): Record<string, unknown> {
const parts = token.split('.');
if (parts.length < 2) {
return {};
}
const payload = base64UrlDecode(parts[1]);
const json = new TextDecoder().decode(payload);
try {
return JSON.parse(json) as Record<string, unknown>;
} catch {
return {};
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (value) =>
value.toString(16).padStart(2, '0')
).join('');
}

View File

@@ -0,0 +1,181 @@
import { Injectable } from '@angular/core';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { computeJwkThumbprint } from './jose-utilities';
const DB_NAME = 'stellaops-auth';
const STORE_NAME = 'dpopKeys';
const PRIMARY_KEY = 'primary';
const DB_VERSION = 1;
interface PersistedKeyPair {
readonly id: string;
readonly algorithm: DPoPAlgorithm;
readonly publicJwk: JsonWebKey;
readonly privateJwk: JsonWebKey;
readonly thumbprint: string;
readonly createdAtIso: string;
}
export interface LoadedDpopKeyPair {
readonly algorithm: DPoPAlgorithm;
readonly privateKey: CryptoKey;
readonly publicKey: CryptoKey;
readonly publicJwk: JsonWebKey;
readonly thumbprint: string;
}
@Injectable({
providedIn: 'root',
})
export class DpopKeyStore {
private dbPromise: Promise<IDBDatabase> | null = null;
async load(): Promise<LoadedDpopKeyPair | null> {
const record = await this.read();
if (!record) {
return null;
}
const [privateKey, publicKey] = await Promise.all([
crypto.subtle.importKey(
'jwk',
record.privateJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['sign']
),
crypto.subtle.importKey(
'jwk',
record.publicJwk,
this.toKeyAlgorithm(record.algorithm),
true,
['verify']
),
]);
return {
algorithm: record.algorithm,
privateKey,
publicKey,
publicJwk: record.publicJwk,
thumbprint: record.thumbprint,
};
}
async save(
keyPair: CryptoKeyPair,
algorithm: DPoPAlgorithm
): Promise<LoadedDpopKeyPair> {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
if (!publicJwk) {
throw new Error('Failed to export public JWK for DPoP key pair.');
}
const thumbprint = await computeJwkThumbprint(publicJwk);
const record: PersistedKeyPair = {
id: PRIMARY_KEY,
algorithm,
publicJwk,
privateJwk,
thumbprint,
createdAtIso: new Date().toISOString(),
};
await this.write(record);
return {
algorithm,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
publicJwk,
thumbprint,
};
}
async clear(): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.delete(PRIMARY_KEY)
);
}
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
const algo = this.toKeyAlgorithm(algorithm);
const keyPair = await crypto.subtle.generateKey(algo, true, [
'sign',
'verify',
]);
const stored = await this.save(keyPair, algorithm);
return stored;
}
private async read(): Promise<PersistedKeyPair | null> {
const db = await this.openDb();
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
store.get(PRIMARY_KEY)
);
}
private async write(record: PersistedKeyPair): Promise<void> {
const db = await this.openDb();
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
store.put(record)
);
}
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
switch (algorithm) {
case 'ES384':
return { name: 'ECDSA', namedCurve: 'P-384' };
case 'EdDSA':
throw new Error('EdDSA DPoP keys are not yet supported.');
case 'ES256':
default:
return { name: 'ECDSA', namedCurve: 'P-256' };
}
}
private async openDb(): Promise<IDBDatabase> {
if (typeof indexedDB === 'undefined') {
throw new Error('IndexedDB is not available for DPoP key persistence.');
}
if (!this.dbPromise) {
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return this.dbPromise;
}
}
function transactionPromise<T>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
executor: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = executor(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}

View File

@@ -0,0 +1,103 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
import { AppConfigService } from '../../config/app-config.service';
import { base64UrlDecode } from './jose-utilities';
import { DpopKeyStore } from './dpop-key-store';
import { DpopService } from './dpop.service';
describe('DpopService', () => {
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
const config: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
beforeEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
DpopKeyStore,
DpopService,
{
provide: APP_CONFIG,
useValue: config,
},
],
});
});
afterEach(async () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
const store = TestBed.inject(DpopKeyStore);
try {
await store.clear();
} catch {
// ignore cleanup issues in test environment
}
});
it('creates a DPoP proof with expected header values', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const proof = await service.createProof({
htm: 'get',
htu: 'https://scanner.stellaops.test/api/v1/scans',
});
const [rawHeader, rawPayload] = proof.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawHeader))
);
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(rawPayload))
);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk.kty).toBe('EC');
expect(payload.htm).toBe('GET');
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.jti).toBe('string');
});
it('binds access token hash when provided', async () => {
const appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(config);
const service = TestBed.inject(DpopService);
const accessToken = 'sample-access-token';
const proof = await service.createProof({
htm: 'post',
htu: 'https://scanner.stellaops.test/api/v1/scans',
accessToken,
});
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
);
expect(payload.ath).toBeDefined();
expect(typeof payload.ath).toBe('string');
});
});

View File

@@ -0,0 +1,148 @@
import { Injectable, computed, signal } from '@angular/core';
import { AppConfigService } from '../../config/app-config.service';
import { DPoPAlgorithm } from '../../config/app-config.model';
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
export interface DpopProofOptions {
readonly htm: string;
readonly htu: string;
readonly accessToken?: string;
readonly nonce?: string | null;
}
@Injectable({
providedIn: 'root',
})
export class DpopService {
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
private readonly nonceSignal = signal<string | null>(null);
readonly nonce = computed(() => this.nonceSignal());
constructor(
private readonly config: AppConfigService,
private readonly store: DpopKeyStore
) {}
async setNonce(nonce: string | null): Promise<void> {
this.nonceSignal.set(nonce);
}
async getThumbprint(): Promise<string | null> {
const key = await this.getOrCreateKeyPair();
return key.thumbprint ?? null;
}
async rotateKey(): Promise<void> {
const algorithm = this.resolveAlgorithm();
this.keyPairPromise = this.store.generate(algorithm);
}
async createProof(options: DpopProofOptions): Promise<string> {
const keyPair = await this.getOrCreateKeyPair();
const header = {
typ: 'dpop+jwt',
alg: keyPair.algorithm,
jwk: keyPair.publicJwk,
};
const nowSeconds = Math.floor(Date.now() / 1000);
const payload: Record<string, unknown> = {
htm: options.htm.toUpperCase(),
htu: normalizeHtu(options.htu),
iat: nowSeconds,
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
};
const nonce = options.nonce ?? this.nonceSignal();
if (nonce) {
payload['nonce'] = nonce;
}
if (options.accessToken) {
const accessTokenHash = await sha256(
new TextEncoder().encode(options.accessToken)
);
payload['ath'] = base64UrlEncode(accessTokenHash);
}
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = await crypto.subtle.sign(
{
name: 'ECDSA',
hash: this.resolveHashAlgorithm(keyPair.algorithm),
},
keyPair.privateKey,
new TextEncoder().encode(signingInput)
);
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
return `${signingInput}.${joseSignature}`;
}
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadKeyPair();
}
try {
return await this.keyPairPromise;
} catch (error) {
// Reset the memoized promise so a subsequent call can retry.
this.keyPairPromise = null;
throw error;
}
}
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
const algorithm = this.resolveAlgorithm();
try {
const existing = await this.store.load();
if (existing && existing.algorithm === algorithm) {
return existing;
}
} catch {
// fall through to regeneration
}
return this.store.generate(algorithm);
}
private resolveAlgorithm(): DPoPAlgorithm {
const authority = this.config.authority;
return authority.dpopAlgorithms?.[0] ?? 'ES256';
}
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
switch (algorithm) {
case 'ES384':
return 'SHA-384';
case 'ES256':
default:
return 'SHA-256';
}
}
}
function normalizeHtu(value: string): string {
try {
const base =
typeof window !== 'undefined' && window.location
? window.location.origin
: undefined;
const url = base ? new URL(value, base) : new URL(value);
url.hash = '';
return url.toString();
} catch {
return value;
}
}
function createRandomId(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}

View File

@@ -0,0 +1,123 @@
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
export function base64UrlEncode(
input: ArrayBuffer | Uint8Array | string
): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else {
bytes = new Uint8Array(input);
}
let binary = '';
for (let i = 0; i < bytes.byteLength; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded =
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
const canonical = canonicalizeJwk(jwk);
const digest = await sha256(new TextEncoder().encode(canonical));
return base64UrlEncode(digest);
}
function canonicalizeJwk(jwk: JsonWebKey): string {
if (!jwk.kty) {
throw new Error('JWK must include "kty"');
}
if (jwk.kty === 'EC') {
const { crv, kty, x, y } = jwk;
if (!crv || !x || !y) {
throw new Error('EC JWK must include "crv", "x", and "y".');
}
return JSON.stringify({ crv, kty, x, y });
}
if (jwk.kty === 'OKP') {
const { crv, kty, x } = jwk;
if (!crv || !x) {
throw new Error('OKP JWK must include "crv" and "x".');
}
return JSON.stringify({ crv, kty, x });
}
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
}
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
const bytes = new Uint8Array(der);
if (bytes[0] !== 0x30) {
// Some implementations already return raw (r || s) signature bytes.
if (bytes.length === 64) {
return bytes;
}
throw new Error('Invalid DER signature: expected sequence.');
}
let offset = 2; // skip SEQUENCE header and length (assume short form)
if (bytes[1] & 0x80) {
const lengthBytes = bytes[1] & 0x7f;
offset = 2 + lengthBytes;
}
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for r.');
}
const rLength = bytes[offset + 1];
let r = bytes.slice(offset + 2, offset + 2 + rLength);
offset = offset + 2 + rLength;
if (bytes[offset] !== 0x02) {
throw new Error('Invalid DER signature: expected INTEGER for s.');
}
const sLength = bytes[offset + 1];
let s = bytes.slice(offset + 2, offset + 2 + sLength);
r = trimLeadingZeros(r);
s = trimLeadingZeros(s);
const targetLength = 32;
const signature = new Uint8Array(targetLength * 2);
signature.set(padStart(r, targetLength), 0);
signature.set(padStart(s, targetLength), targetLength);
return signature;
}
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0x00) {
start += 1;
}
return bytes.subarray(start);
}
function padStart(bytes: Uint8Array, length: number): Uint8Array {
if (bytes.length >= length) {
return bytes;
}
const padded = new Uint8Array(length);
padded.set(bytes, length - bytes.length);
return padded;
}

View File

@@ -0,0 +1,24 @@
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
export interface PkcePair {
readonly verifier: string;
readonly challenge: string;
readonly method: 'S256';
}
const VERIFIER_BYTE_LENGTH = 32;
export async function createPkcePair(): Promise<PkcePair> {
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
crypto.getRandomValues(verifierBytes);
const verifier = base64UrlEncode(verifierBytes);
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
const challenge = base64UrlEncode(challengeBytes);
return {
verifier,
challenge,
method: 'S256',
};
}

View File

@@ -0,0 +1,49 @@
import { InjectionToken } from '@angular/core';
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
export interface AuthorityConfig {
readonly issuer: string;
readonly clientId: string;
readonly authorizeEndpoint: string;
readonly tokenEndpoint: string;
readonly logoutEndpoint?: string;
readonly redirectUri: string;
readonly postLogoutRedirectUri?: string;
readonly scope: string;
readonly audience: string;
/**
* Preferred algorithms for DPoP proofs, in order of preference.
* Defaults to ES256 if omitted.
*/
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
/**
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
* Defaults to 60.
*/
readonly refreshLeewaySeconds?: number;
}
export interface ApiBaseUrlConfig {
readonly scanner: string;
readonly policy: string;
readonly concelier: string;
readonly excitor?: string;
readonly attestor: string;
readonly authority: string;
readonly notify?: string;
readonly scheduler?: string;
}
export interface TelemetryConfig {
readonly otlpEndpoint?: string;
readonly sampleRate?: number;
}
export interface AppConfig {
readonly authority: AuthorityConfig;
readonly apiBaseUrls: ApiBaseUrlConfig;
readonly telemetry?: TelemetryConfig;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('STELLAOPS_APP_CONFIG');

View File

@@ -0,0 +1,99 @@
import { HttpClient } from '@angular/common/http';
import {
Inject,
Injectable,
Optional,
computed,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
APP_CONFIG,
AppConfig,
AuthorityConfig,
DPoPAlgorithm,
} from './app-config.model';
const DEFAULT_CONFIG_URL = '/config.json';
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
@Injectable({
providedIn: 'root',
})
export class AppConfigService {
private readonly configSignal = signal<AppConfig | null>(null);
private readonly authoritySignal = computed<AuthorityConfig | null>(() => {
const config = this.configSignal();
return config?.authority ?? null;
});
constructor(
private readonly http: HttpClient,
@Optional() @Inject(APP_CONFIG) private readonly staticConfig: AppConfig | null
) {}
/**
* Loads application configuration either from the injected static value or via HTTP fetch.
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
*/
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
if (this.configSignal()) {
return;
}
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
this.configSignal.set(this.normalizeConfig(config));
}
/**
* Allows tests to short-circuit configuration loading.
*/
setConfigForTesting(config: AppConfig): void {
this.configSignal.set(this.normalizeConfig(config));
}
get config(): AppConfig {
const current = this.configSignal();
if (!current) {
throw new Error('App configuration has not been loaded yet.');
}
return current;
}
get authority(): AuthorityConfig {
const authority = this.authoritySignal();
if (!authority) {
throw new Error('Authority configuration has not been loaded yet.');
}
return authority;
}
private async fetchConfig(configUrl: string): Promise<AppConfig> {
const response = await firstValueFrom(
this.http.get<AppConfig>(configUrl, {
headers: { 'Cache-Control': 'no-cache' },
withCredentials: false,
})
);
return response;
}
private normalizeConfig(config: AppConfig): AppConfig {
const authority = {
...config.authority,
dpopAlgorithms:
config.authority.dpopAlgorithms?.length ?? 0
? config.authority.dpopAlgorithms
: [DEFAULT_DPOP_ALG],
refreshLeewaySeconds:
config.authority.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS,
};
return {
...config,
authority,
};
}
}

View File

@@ -0,0 +1,139 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
TenantCatalogResponseDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleSessionService } from './console-session.service';
import { ConsoleSessionStore } from './console-session.store';
class MockConsoleApi implements AuthorityConsoleApi {
private createTenantResponse(): TenantCatalogResponseDto {
return {
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
};
}
listTenants() {
return of(this.createTenantResponse());
}
getProfile() {
return of({
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
introspectToken() {
return of({
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: '2025-10-31T12:00:00Z',
authenticationTime: '2025-10-31T12:00:00Z',
expiresAt: '2025-10-31T12:10:00Z',
freshAuth: true,
});
}
}
class MockAuthSessionStore {
private tenantIdValue: string | null = 'tenant-default';
private readonly sessionValue = {
tenantId: 'tenant-default',
scopes: ['ui.read'],
audiences: ['console'],
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
};
session = () => this.sessionValue as any;
getActiveTenantId(): string | null {
return this.tenantIdValue;
}
setTenantId(tenantId: string | null): void {
this.tenantIdValue = tenantId;
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
}
}
describe('ConsoleSessionService', () => {
let service: ConsoleSessionService;
let store: ConsoleSessionStore;
let authStore: MockAuthSessionStore;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConsoleSessionStore,
ConsoleSessionService,
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
});
service = TestBed.inject(ConsoleSessionService);
store = TestBed.inject(ConsoleSessionStore);
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
});
it('loads console context for active tenant', async () => {
await service.loadConsoleContext();
expect(store.tenants().length).toBe(1);
expect(store.selectedTenantId()).toBe('tenant-default');
expect(store.profile()?.displayName).toBe('Console User');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
});
it('clears store when no tenant available', async () => {
authStore.setTenantId(null);
store.setTenants(
[
{
id: 'existing',
displayName: 'Existing',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'existing'
);
await service.loadConsoleContext();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
});
});

View File

@@ -0,0 +1,161 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
AUTHORITY_CONSOLE_API,
AuthorityConsoleApi,
AuthorityTenantViewDto,
ConsoleProfileDto,
ConsoleTokenIntrospectionDto,
} from '../api/authority-console.client';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
ConsoleProfile,
ConsoleSessionStore,
ConsoleTenant,
ConsoleTokenInfo,
} from './console-session.store';
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionService {
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
private readonly store = inject(ConsoleSessionStore);
private readonly authSession = inject(AuthSessionStore);
async loadConsoleContext(tenantId?: string | null): Promise<void> {
const activeTenant =
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!activeTenant) {
this.store.clear();
return;
}
this.store.setSelectedTenant(activeTenant);
this.store.setLoading(true);
this.store.setError(null);
try {
const tenantResponse = await firstValueFrom(
this.api.listTenants(activeTenant)
);
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
this.mapTenant(tenant)
);
const [profileDto, tokenDto] = await Promise.all([
firstValueFrom(this.api.getProfile(activeTenant)),
firstValueFrom(this.api.introspectToken(activeTenant)),
]);
const profile = this.mapProfile(profileDto);
const tokenInfo = this.mapTokenInfo(tokenDto);
this.store.setContext({
tenants,
profile,
token: tokenInfo,
selectedTenantId: activeTenant,
});
} catch (error) {
console.error('Failed to load console context', error);
this.store.setError('Unable to load console context.');
} finally {
this.store.setLoading(false);
}
}
async switchTenant(tenantId: string): Promise<void> {
if (!tenantId || tenantId === this.store.selectedTenantId()) {
return this.loadConsoleContext(tenantId);
}
this.store.setSelectedTenant(tenantId);
await this.loadConsoleContext(tenantId);
}
async refresh(): Promise<void> {
await this.loadConsoleContext(this.store.selectedTenantId());
}
clear(): void {
this.store.clear();
}
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
const roles = Array.isArray(dto.defaultRoles)
? dto.defaultRoles
.map((role) => role.trim())
.filter((role) => role.length > 0)
.sort((a, b) => a.localeCompare(b))
: [];
return {
id: dto.id,
displayName: dto.displayName || dto.id,
status: dto.status ?? 'active',
isolationMode: dto.isolationMode ?? 'shared',
defaultRoles: roles,
};
}
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
return {
subjectId: dto.subjectId ?? null,
username: dto.username ?? null,
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
tenant: dto.tenant,
sessionId: dto.sessionId ?? null,
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
authenticationMethods: [...(dto.authenticationMethods ?? [])],
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime: this.parseInstant(dto.authenticationTime),
expiresAt: this.parseInstant(dto.expiresAt),
freshAuth: !!dto.freshAuth,
};
}
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
const session = this.authSession.session();
const freshAuthExpiresAt =
session?.freshAuthExpiresAtEpochMs != null
? new Date(session.freshAuthExpiresAtEpochMs)
: null;
const authenticationTime =
session?.authenticationTimeEpochMs != null
? new Date(session.authenticationTimeEpochMs)
: this.parseInstant(dto.authenticationTime);
return {
active: !!dto.active,
tenant: dto.tenant,
subject: dto.subject ?? null,
clientId: dto.clientId ?? null,
tokenId: dto.tokenId ?? null,
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
a.localeCompare(b)
),
issuedAt: this.parseInstant(dto.issuedAt),
authenticationTime,
expiresAt: this.parseInstant(dto.expiresAt),
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
freshAuthExpiresAt,
};
}
private parseInstant(value: string | null | undefined): Date | null {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
}

View File

@@ -0,0 +1,123 @@
import { ConsoleSessionStore } from './console-session.store';
describe('ConsoleSessionStore', () => {
let store: ConsoleSessionStore;
beforeEach(() => {
store = new ConsoleSessionStore();
});
it('tracks tenants and selection', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
{
id: 'tenant-b',
displayName: 'Tenant B',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.b'],
},
];
const selected = store.setTenants(tenants, 'tenant-b');
expect(selected).toBe('tenant-b');
expect(store.selectedTenantId()).toBe('tenant-b');
expect(store.tenants().length).toBe(2);
});
it('sets context with profile and token info', () => {
const tenants = [
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.a'],
},
];
store.setContext({
tenants,
selectedTenantId: 'tenant-a',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'User Example',
tenant: 'tenant-a',
sessionId: 'session-123',
roles: ['role.a'],
scopes: ['scope.a'],
audiences: ['aud'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-a',
subject: 'user-1',
clientId: 'client',
tokenId: 'token-1',
scopes: ['scope.a'],
audiences: ['aud'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
expect(store.selectedTenantId()).toBe('tenant-a');
expect(store.profile()?.displayName).toBe('User Example');
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
expect(store.hasContext()).toBeTrue();
});
it('clears state', () => {
store.setTenants(
[
{
id: 'tenant-a',
displayName: 'Tenant A',
status: 'active',
isolationMode: 'shared',
defaultRoles: [],
},
],
'tenant-a'
);
store.setProfile({
subjectId: null,
username: null,
displayName: null,
tenant: 'tenant-a',
sessionId: null,
roles: [],
scopes: [],
audiences: [],
authenticationMethods: [],
issuedAt: null,
authenticationTime: null,
expiresAt: null,
freshAuth: false,
});
store.clear();
expect(store.tenants().length).toBe(0);
expect(store.selectedTenantId()).toBeNull();
expect(store.profile()).toBeNull();
expect(store.tokenInfo()).toBeNull();
expect(store.loading()).toBeFalse();
expect(store.error()).toBeNull();
});
});

View File

@@ -0,0 +1,128 @@
import { Injectable, computed, signal } from '@angular/core';
export interface ConsoleTenant {
readonly id: string;
readonly displayName: string;
readonly status: string;
readonly isolationMode: string;
readonly defaultRoles: readonly string[];
}
export interface ConsoleProfile {
readonly subjectId: string | null;
readonly username: string | null;
readonly displayName: string | null;
readonly tenant: string;
readonly sessionId: string | null;
readonly roles: readonly string[];
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly authenticationMethods: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuth: boolean;
}
export interface ConsoleTokenInfo {
readonly active: boolean;
readonly tenant: string;
readonly subject: string | null;
readonly clientId: string | null;
readonly tokenId: string | null;
readonly scopes: readonly string[];
readonly audiences: readonly string[];
readonly issuedAt: Date | null;
readonly authenticationTime: Date | null;
readonly expiresAt: Date | null;
readonly freshAuthActive: boolean;
readonly freshAuthExpiresAt: Date | null;
}
@Injectable({
providedIn: 'root',
})
export class ConsoleSessionStore {
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
private readonly selectedTenantIdSignal = signal<string | null>(null);
private readonly profileSignal = signal<ConsoleProfile | null>(null);
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly tenants = computed(() => this.tenantsSignal());
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
readonly profile = computed(() => this.profileSignal());
readonly tokenInfo = computed(() => this.tokenSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly hasContext = computed(
() =>
this.tenantsSignal().length > 0 ||
this.profileSignal() !== null ||
this.tokenSignal() !== null
);
setLoading(loading: boolean): void {
this.loadingSignal.set(loading);
}
setError(message: string | null): void {
this.errorSignal.set(message);
}
setContext(context: {
tenants: ConsoleTenant[];
profile: ConsoleProfile | null;
token: ConsoleTokenInfo | null;
selectedTenantId?: string | null;
}): void {
const selected = this.setTenants(context.tenants, context.selectedTenantId);
this.profileSignal.set(context.profile);
this.tokenSignal.set(context.token);
this.selectedTenantIdSignal.set(selected);
}
setProfile(profile: ConsoleProfile | null): void {
this.profileSignal.set(profile);
}
setTokenInfo(token: ConsoleTokenInfo | null): void {
this.tokenSignal.set(token);
}
setTenants(
tenants: ConsoleTenant[],
preferredTenantId?: string | null
): string | null {
this.tenantsSignal.set(tenants);
const currentSelection = this.selectedTenantIdSignal();
const fallbackSelection =
tenants.length > 0 ? tenants[0].id : null;
const nextSelection =
(preferredTenantId &&
tenants.some((tenant) => tenant.id === preferredTenantId) &&
preferredTenantId) ||
(currentSelection &&
tenants.some((tenant) => tenant.id === currentSelection) &&
currentSelection) ||
fallbackSelection;
this.selectedTenantIdSignal.set(nextSelection);
return nextSelection;
}
setSelectedTenant(tenantId: string | null): void {
this.selectedTenantIdSignal.set(tenantId);
}
clear(): void {
this.tenantsSignal.set([]);
this.selectedTenantIdSignal.set(null);
this.profileSignal.set(null);
this.tokenSignal.set(null);
this.loadingSignal.set(false);
this.errorSignal.set(null);
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable, signal } from '@angular/core';
export interface OperatorContext {
readonly reason: string;
readonly ticket: string;
}
@Injectable({
providedIn: 'root',
})
export class OperatorContextService {
private readonly contextSignal = signal<OperatorContext | null>(null);
readonly context = this.contextSignal.asReadonly();
setContext(reason: string, ticket: string): void {
const normalizedReason = reason.trim();
const normalizedTicket = ticket.trim();
if (!normalizedReason || !normalizedTicket) {
throw new Error(
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
);
}
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
}
clear(): void {
this.contextSignal.set(null);
}
snapshot(): OperatorContext | null {
return this.contextSignal();
}
}

View File

@@ -0,0 +1,41 @@
import {
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { OperatorContextService } from './operator-context.service';
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
@Injectable()
export class OperatorMetadataInterceptor implements HttpInterceptor {
constructor(private readonly context: OperatorContextService) {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
return next.handle(request);
}
const current = this.context.snapshot();
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
if (!current) {
return next.handle(request.clone({ headers }));
}
const enriched = headers
.set(OPERATOR_REASON_HEADER, current.reason)
.set(OPERATOR_TICKET_HEADER, current.ticket);
return next.handle(request.clone({ headers: enriched }));
}
}

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
@Component({
selector: 'app-auth-callback',
standalone: true,
imports: [CommonModule],
template: `
<section class="auth-callback">
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
<p *ngIf="state() === 'error'" class="error">
We were unable to complete the sign-in flow. Please try again.
</p>
</section>
`,
styles: [
`
.auth-callback {
margin: 4rem auto;
max-width: 420px;
text-align: center;
font-size: 1rem;
color: #0f172a;
}
.error {
color: #dc2626;
font-weight: 500;
}
`,
],
})
export class AuthCallbackComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
readonly state = signal<'processing' | 'error'>('processing');
async ngOnInit(): Promise<void> {
const params = this.route.snapshot.queryParamMap;
const searchParams = new URLSearchParams();
params.keys.forEach((key) => {
const value = params.get(key);
if (value != null) {
searchParams.set(key, value);
}
});
try {
const result = await this.auth.completeLoginFromRedirect(searchParams);
const returnUrl = result.returnUrl ?? '/';
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
} catch {
this.state.set('error');
}
}
}

View File

@@ -0,0 +1,208 @@
<section class="console-profile">
<header class="console-profile__header">
<div>
<h1>Console Session</h1>
<p class="console-profile__subtitle">
Session details sourced from Authority console endpoints.
</p>
</div>
<button
type="button"
(click)="refresh()"
[disabled]="loading()"
aria-busy="{{ loading() }}"
>
Refresh
</button>
</header>
<div class="console-profile__error" *ngIf="error() as message">
{{ message }}
</div>
<div class="console-profile__loading" *ngIf="loading()">
Loading console context…
</div>
<ng-container *ngIf="!loading()">
<section class="console-profile__card" *ngIf="profile() as profile">
<header>
<h2>User Profile</h2>
<span class="tenant-chip">
Tenant
<strong>{{ profile.tenant }}</strong>
</span>
</header>
<dl>
<div>
<dt>Display name</dt>
<dd>{{ profile.displayName || 'n/a' }}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{{ profile.username || 'n/a' }}</dd>
</div>
<div>
<dt>Subject</dt>
<dd>{{ profile.subjectId || 'n/a' }}</dd>
</div>
<div>
<dt>Session ID</dt>
<dd>{{ profile.sessionId || 'n/a' }}</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<span *ngIf="profile.roles.length; else noRoles">
{{ profile.roles.join(', ') }}
</span>
<ng-template #noRoles>n/a</ng-template>
</dd>
</div>
<div>
<dt>Scopes</dt>
<dd>
<span *ngIf="profile.scopes.length; else noScopes">
{{ profile.scopes.join(', ') }}
</span>
<ng-template #noScopes>n/a</ng-template>
</dd>
</div>
<div>
<dt>Audiences</dt>
<dd>
<span *ngIf="profile.audiences.length; else noAudiences">
{{ profile.audiences.join(', ') }}
</span>
<ng-template #noAudiences>n/a</ng-template>
</dd>
</div>
<div>
<dt>Authentication methods</dt>
<dd>
<span
*ngIf="profile.authenticationMethods.length; else noAuthMethods"
>
{{ profile.authenticationMethods.join(', ') }}
</span>
<ng-template #noAuthMethods>n/a</ng-template>
</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
profile.authenticationTime
? (profile.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
</section>
<section class="console-profile__card" *ngIf="tokenInfo() as token">
<header>
<h2>Access Token</h2>
<span
class="chip"
[class.chip--active]="token.active"
[class.chip--inactive]="!token.active"
>
{{ token.active ? 'Active' : 'Inactive' }}
</span>
</header>
<dl>
<div>
<dt>Token ID</dt>
<dd>{{ token.tokenId || 'n/a' }}</dd>
</div>
<div>
<dt>Client ID</dt>
<dd>{{ token.clientId || 'n/a' }}</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ token.issuedAt ? (token.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
token.authenticationTime
? (token.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ token.expiresAt ? (token.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
<div
class="fresh-auth"
*ngIf="freshAuthState() as fresh"
[class.fresh-auth--active]="fresh.active"
[class.fresh-auth--stale]="!fresh.active"
>
Fresh auth:
<strong>{{ fresh.active ? 'Active' : 'Stale' }}</strong>
<ng-container *ngIf="fresh.expiresAt">
(expires {{ fresh.expiresAt | date : 'mediumTime' }})
</ng-container>
</div>
</section>
<section class="console-profile__card" *ngIf="tenantCount() > 0">
<header>
<h2>Accessible Tenants</h2>
<span class="tenant-count">{{ tenantCount() }} total</span>
</header>
<ul class="tenant-list">
<li
*ngFor="let tenant of tenants()"
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
>
<button type="button" (click)="selectTenant(tenant.id)">
<div class="tenant-list__heading">
<span class="tenant-name">{{ tenant.displayName }}</span>
<span class="tenant-status">{{ tenant.status }}</span>
</div>
<div class="tenant-meta">
Isolation: {{ tenant.isolationMode }} · Default roles:
<span *ngIf="tenant.defaultRoles.length; else noTenantRoles">
{{ tenant.defaultRoles.join(', ') }}
</span>
<ng-template #noTenantRoles>n/a</ng-template>
</div>
</button>
</li>
</ul>
</section>
<p class="console-profile__empty" *ngIf="!hasProfile() && tenantCount() === 0">
No console session data available for the current identity.
</p>
</ng-container>
</section>

View File

@@ -0,0 +1,220 @@
.console-profile {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.console-profile__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #0f172a;
}
.console-profile__subtitle {
margin: 0.25rem 0 0;
color: #475569;
font-size: 0.95rem;
}
button {
appearance: none;
border: none;
border-radius: 0.75rem;
padding: 0.5rem 1.2rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(90deg, #4f46e5 0%, #9333ea 100%);
color: #f8fafc;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover,
&:focus-visible {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.28);
}
&:disabled {
cursor: progress;
opacity: 0.75;
transform: none;
box-shadow: none;
}
}
}
.console-profile__loading {
padding: 1rem 1.25rem;
border-radius: 0.75rem;
background: rgba(79, 70, 229, 0.08);
color: #4338ca;
font-weight: 500;
}
.console-profile__error {
padding: 1rem 1.25rem;
border-radius: 0.75rem;
background: rgba(220, 38, 38, 0.08);
color: #b91c1c;
border: 1px solid rgba(220, 38, 38, 0.24);
}
.console-profile__card {
background: linear-gradient(150deg, #ffffff 0%, #f8f9ff 100%);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(79, 70, 229, 0.08);
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
}
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem 1.5rem;
margin: 0;
dt {
margin: 0 0 0.25rem;
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
dd {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
word-break: break-word;
}
}
}
.chip,
.tenant-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background-color: rgba(15, 23, 42, 0.08);
color: #0f172a;
}
.chip--active {
background-color: rgba(22, 163, 74, 0.12);
color: #15803d;
}
.chip--inactive {
background-color: rgba(220, 38, 38, 0.12);
color: #b91c1c;
}
.tenant-chip {
background-color: rgba(79, 70, 229, 0.12);
color: #4338ca;
}
.tenant-count {
font-size: 0.85rem;
font-weight: 500;
color: #475569;
}
.tenant-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.75rem;
}
.tenant-list__item--active button {
border-color: rgba(79, 70, 229, 0.45);
background-color: rgba(79, 70, 229, 0.08);
}
.tenant-list button {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(100, 116, 139, 0.18);
background: #ffffff;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover,
&:focus-visible {
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.16);
transform: translateY(-1px);
}
}
.tenant-list__heading {
display: flex;
justify-content: space-between;
width: 100%;
font-weight: 600;
color: #1e293b;
}
.tenant-meta {
font-size: 0.85rem;
color: #475569;
}
.fresh-auth {
margin-top: 1.25rem;
padding: 0.6rem 0.9rem;
border-radius: 0.75rem;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.fresh-auth--active {
background-color: rgba(16, 185, 129, 0.1);
color: #047857;
}
.fresh-auth--stale {
background-color: rgba(249, 115, 22, 0.12);
color: #c2410c;
}
.console-profile__empty {
margin: 0;
font-size: 0.95rem;
color: #475569;
}

View File

@@ -0,0 +1,110 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { ConsoleProfileComponent } from './console-profile.component';
class MockConsoleSessionService {
loadConsoleContext = jasmine
.createSpy('loadConsoleContext')
.and.returnValue(Promise.resolve());
refresh = jasmine
.createSpy('refresh')
.and.returnValue(Promise.resolve());
switchTenant = jasmine
.createSpy('switchTenant')
.and.returnValue(Promise.resolve());
}
describe('ConsoleProfileComponent', () => {
let fixture: ComponentFixture<ConsoleProfileComponent>;
let service: MockConsoleSessionService;
let store: ConsoleSessionStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConsoleProfileComponent],
providers: [
ConsoleSessionStore,
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
],
}).compileComponents();
service = TestBed.inject(
ConsoleSessionService
) as unknown as MockConsoleSessionService;
store = TestBed.inject(ConsoleSessionStore);
fixture = TestBed.createComponent(ConsoleProfileComponent);
});
it('renders profile and tenant information', async () => {
store.setContext({
tenants: [
{
id: 'tenant-default',
displayName: 'Tenant Default',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['role.console'],
},
],
selectedTenantId: 'tenant-default',
profile: {
subjectId: 'user-1',
username: 'user@example.com',
displayName: 'Console User',
tenant: 'tenant-default',
sessionId: 'session-1',
roles: ['role.console'],
scopes: ['ui.read'],
audiences: ['console'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuth: true,
},
token: {
active: true,
tenant: 'tenant-default',
subject: 'user-1',
clientId: 'console-web',
tokenId: 'token-1',
scopes: ['ui.read'],
audiences: ['console'],
issuedAt: new Date('2025-10-31T12:00:00Z'),
authenticationTime: new Date('2025-10-31T12:00:00Z'),
expiresAt: new Date('2025-10-31T12:10:00Z'),
freshAuthActive: true,
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
},
});
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Console Session'
);
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
'Tenant Default'
);
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
expect(service.loadConsoleContext).not.toHaveBeenCalled();
});
it('invokes refresh on demand', async () => {
store.clear();
fixture.detectChanges();
await fixture.whenStable();
const button = fixture.nativeElement.querySelector(
'button[type="button"]'
) as HTMLButtonElement;
button.click();
await fixture.whenStable();
expect(service.refresh).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
} from '@angular/core';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
@Component({
selector: 'app-console-profile',
standalone: true,
imports: [CommonModule],
templateUrl: './console-profile.component.html',
styleUrls: ['./console-profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleProfileComponent implements OnInit {
private readonly store = inject(ConsoleSessionStore);
private readonly service = inject(ConsoleSessionService);
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly profile = this.store.profile;
readonly tokenInfo = this.store.tokenInfo;
readonly tenants = this.store.tenants;
readonly selectedTenantId = this.store.selectedTenantId;
readonly hasProfile = computed(() => this.profile() !== null);
readonly tenantCount = computed(() => this.tenants().length);
readonly freshAuthState = computed(() => {
const token = this.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
async ngOnInit(): Promise<void> {
if (!this.store.hasContext()) {
try {
await this.service.loadConsoleContext();
} catch {
// error surfaced via store
}
}
}
async refresh(): Promise<void> {
try {
await this.service.refresh();
} catch {
// error surfaced via store
}
}
async selectTenant(tenantId: string): Promise<void> {
try {
await this.service.switchTenant(tenantId);
} catch {
// error surfaced via store
}
}
}

View File

@@ -0,0 +1,344 @@
<section class="notify-panel" aria-live="polite">
<header class="notify-panel__header">
<div>
<p class="eyebrow">Notifications</p>
<h1>Notify control plane</h1>
<p>Manage channels, routing rules, deliveries, and preview payloads offline.</p>
</div>
<button
type="button"
class="ghost-button"
(click)="refreshAll()"
[disabled]="channelLoading() || ruleLoading() || deliveriesLoading()"
>
Refresh data
</button>
</header>
<div class="notify-grid">
<article class="notify-card">
<header class="notify-card__header">
<div>
<h2>Channels</h2>
<p>Destinations for Slack, Teams, Email, or Webhook notifications.</p>
</div>
<button type="button" class="ghost-button" (click)="createChannelDraft()">New channel</button>
</header>
<p *ngIf="channelMessage()" class="notify-message" role="status">
{{ channelMessage() }}
</p>
<ul class="channel-list" role="list">
<li *ngFor="let channel of channels(); trackBy: trackByChannel">
<button
type="button"
class="channel-item"
data-testid="channel-item"
[class.active]="selectedChannelId() === channel.channelId"
(click)="selectChannel(channel.channelId)"
>
<span class="channel-name">{{ channel.displayName || channel.name }}</span>
<span class="channel-meta">{{ channel.type }}</span>
<span class="channel-status" [class.channel-status--enabled]="channel.enabled">
{{ channel.enabled ? 'Enabled' : 'Disabled' }}
</span>
</button>
</li>
</ul>
<form
class="channel-form"
[formGroup]="channelForm"
(ngSubmit)="saveChannel()"
novalidate
>
<div class="form-grid">
<label>
<span>Name</span>
<input formControlName="name" type="text" required />
</label>
<label>
<span>Display name</span>
<input formControlName="displayName" type="text" />
</label>
<label>
<span>Type</span>
<select formControlName="type">
<option *ngFor="let type of channelTypes" [value]="type">
{{ type }}
</option>
</select>
</label>
<label>
<span>Secret reference</span>
<input formControlName="secretRef" type="text" required />
</label>
<label>
<span>Target</span>
<input formControlName="target" type="text" placeholder="#alerts or email" />
</label>
<label>
<span>Endpoint</span>
<input formControlName="endpoint" type="text" placeholder="https://example" />
</label>
<label class="full-width">
<span>Description</span>
<textarea formControlName="description" rows="2"></textarea>
</label>
<label class="checkbox">
<input type="checkbox" formControlName="enabled" />
<span>Channel enabled</span>
</label>
</div>
<div class="form-grid">
<label>
<span>Labels (key=value)</span>
<textarea formControlName="labelsText" rows="2" placeholder="tier=critical"></textarea>
</label>
<label>
<span>Metadata (key=value)</span>
<textarea formControlName="metadataText" rows="2" placeholder="workspace=stellaops"></textarea>
</label>
</div>
<div class="notify-actions">
<button type="button" class="ghost-button" (click)="createChannelDraft()">
Reset
</button>
<button
type="button"
class="ghost-button"
(click)="deleteChannel()"
[disabled]="channelLoading() || !selectedChannelId()"
>
Delete
</button>
<button type="submit" [disabled]="channelLoading()">
Save channel
</button>
</div>
</form>
<section *ngIf="channelHealth() as health" class="channel-health" aria-live="polite">
<div class="status-pill" [class.status-pill--healthy]="health.status === 'Healthy'" [class.status-pill--warning]="health.status === 'Degraded'" [class.status-pill--error]="health.status === 'Unhealthy'">
{{ health.status }}
</div>
<div class="channel-health__details">
<p>{{ health.message }}</p>
<small>Last checked {{ health.checkedAt | date: 'medium' }} • Trace {{ health.traceId }}</small>
</div>
</section>
<form class="test-form" [formGroup]="testForm" (ngSubmit)="sendTestPreview()" novalidate>
<h3>Test send</h3>
<div class="form-grid">
<label>
<span>Preview title</span>
<input formControlName="title" type="text" />
</label>
<label>
<span>Summary</span>
<input formControlName="summary" type="text" />
</label>
<label>
<span>Override target</span>
<input formControlName="target" type="text" placeholder="#alerts or user@org" />
</label>
</div>
<label>
<span>Body</span>
<textarea formControlName="body" rows="3"></textarea>
</label>
<div class="notify-actions">
<button type="submit" [disabled]="testSending()">
{{ testSending() ? 'Sending…' : 'Send test' }}
</button>
</div>
</form>
<section *ngIf="testPreview() as preview" class="test-preview" data-testid="test-preview">
<header>
<strong>Preview queued</strong>
<span>{{ preview.queuedAt | date: 'short' }}</span>
</header>
<p><span>Target:</span> {{ preview.preview.target }}</p>
<p><span>Title:</span> {{ preview.preview.title }}</p>
<p><span>Summary:</span> {{ preview.preview.summary || 'n/a' }}</p>
<p class="preview-body">{{ preview.preview.body }}</p>
</section>
</article>
<article class="notify-card">
<header class="notify-card__header">
<div>
<h2>Rules</h2>
<p>Define routing logic and throttles per channel.</p>
</div>
<button type="button" class="ghost-button" (click)="createRuleDraft()">New rule</button>
</header>
<p *ngIf="ruleMessage()" class="notify-message" role="status">
{{ ruleMessage() }}
</p>
<ul class="rule-list" role="list">
<li *ngFor="let rule of rules(); trackBy: trackByRule">
<button
type="button"
class="rule-item"
data-testid="rule-item"
[class.active]="selectedRuleId() === rule.ruleId"
(click)="selectRule(rule.ruleId)"
>
<span class="rule-name">{{ rule.name }}</span>
<span class="rule-meta">{{ rule.match?.minSeverity || 'any' }}</span>
</button>
</li>
</ul>
<form class="rule-form" [formGroup]="ruleForm" (ngSubmit)="saveRule()" novalidate>
<div class="form-grid">
<label>
<span>Name</span>
<input formControlName="name" type="text" required />
</label>
<label>
<span>Minimum severity</span>
<select formControlName="minSeverity">
<option value="">Any</option>
<option *ngFor="let sev of severityOptions" [value]="sev">
{{ sev }}
</option>
</select>
</label>
<label>
<span>Channel</span>
<select formControlName="channel" required>
<option *ngFor="let channel of channels()" [value]="channel.channelId">
{{ channel.displayName || channel.name }}
</option>
</select>
</label>
<label>
<span>Digest</span>
<input formControlName="digest" type="text" placeholder="instant or 1h" />
</label>
<label>
<span>Template</span>
<input formControlName="template" type="text" placeholder="tmpl-critical" />
</label>
<label>
<span>Locale</span>
<input formControlName="locale" type="text" placeholder="en-US" />
</label>
<label>
<span>Throttle (seconds)</span>
<input formControlName="throttleSeconds" type="number" min="0" />
</label>
<label class="checkbox">
<input type="checkbox" formControlName="enabled" />
<span>Rule enabled</span>
</label>
</div>
<label>
<span>Event kinds (comma or newline)</span>
<textarea formControlName="eventKindsText" rows="2"></textarea>
</label>
<label>
<span>Labels filter</span>
<textarea formControlName="labelsText" rows="2" placeholder="kev,critical"></textarea>
</label>
<label>
<span>Description</span>
<textarea formControlName="description" rows="2"></textarea>
</label>
<div class="notify-actions">
<button type="button" class="ghost-button" (click)="createRuleDraft()">
Reset
</button>
<button
type="button"
class="ghost-button"
(click)="deleteRule()"
[disabled]="ruleLoading() || !selectedRuleId()"
>
Delete
</button>
<button type="submit" [disabled]="ruleLoading()">
Save rule
</button>
</div>
</form>
</article>
<article class="notify-card notify-card--deliveries">
<header class="notify-card__header">
<div>
<h2>Deliveries</h2>
<p>Recent delivery attempts, statuses, and preview traces.</p>
</div>
<button type="button" class="ghost-button" (click)="refreshDeliveries()" [disabled]="deliveriesLoading()">Refresh</button>
</header>
<div class="deliveries-controls">
<label>
<span>Status filter</span>
<select [value]="deliveryFilter()" (change)="onDeliveryFilterChange($any($event.target).value)">
<option value="all">All</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="pending">Pending</option>
<option value="throttled">Throttled</option>
<option value="digested">Digested</option>
<option value="dropped">Dropped</option>
</select>
</label>
</div>
<p *ngIf="deliveriesMessage()" class="notify-message" role="status">
{{ deliveriesMessage() }}
</p>
<div class="deliveries-table">
<table>
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Kind</th>
<th scope="col">Created</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let delivery of filteredDeliveries(); trackBy: trackByDelivery"
data-testid="delivery-row"
>
<td>
<span class="status-badge" [class.status-badge--sent]="delivery.status === 'Sent'" [class.status-badge--failed]="delivery.status === 'Failed'" [class.status-badge--throttled]="delivery.status === 'Throttled'">
{{ delivery.status }}
</span>
</td>
<td>
{{ delivery.rendered?.target || 'n/a' }}
</td>
<td>
{{ delivery.kind }}
</td>
<td>
{{ delivery.createdAt | date: 'short' }}
</td>
</tr>
<tr *ngIf="!deliveriesLoading() && !filteredDeliveries().length">
<td colspan="4" class="empty-row">No deliveries match this filter.</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</section>

View File

@@ -0,0 +1,386 @@
:host {
display: block;
color: #e2e8f0;
}
.notify-panel {
background: #0f172a;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45);
}
.notify-panel__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
h1 {
margin: 0.25rem 0;
font-size: 1.75rem;
}
p {
margin: 0;
color: #cbd5f5;
}
}
.eyebrow {
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.1em;
color: #94a3b8;
margin: 0;
}
.notify-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.notify-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 100%;
}
.notify-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
h2 {
margin: 0;
font-size: 1.25rem;
}
p {
margin: 0;
color: #94a3b8;
font-size: 0.9rem;
}
}
.ghost-button {
border: 1px solid rgba(148, 163, 184, 0.4);
background: transparent;
color: #e2e8f0;
border-radius: 999px;
padding: 0.35rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
&:hover,
&:focus-visible {
border-color: #38bdf8;
background: rgba(56, 189, 248, 0.15);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.notify-message {
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: rgba(59, 130, 246, 0.15);
color: #e0f2fe;
font-size: 0.9rem;
}
.channel-list,
.rule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.channel-item,
.rule-item {
width: 100%;
border: 1px solid #1f2937;
background: #0f172a;
color: inherit;
border-radius: 12px;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease;
&.active {
border-color: #38bdf8;
background: rgba(56, 189, 248, 0.1);
transform: translateY(-1px);
}
}
.channel-meta,
.rule-meta {
font-size: 0.8rem;
color: #94a3b8;
}
.channel-status {
font-size: 0.75rem;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid rgba(248, 250, 252, 0.2);
}
.channel-status--enabled {
border-color: #34d399;
color: #34d399;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
width: 100%;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
label span {
color: #cbd5f5;
font-weight: 500;
}
input,
textarea,
select {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 10px;
color: inherit;
padding: 0.6rem;
font-size: 0.95rem;
font-family: inherit;
&:focus-visible {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
}
.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-weight: 500;
input {
width: auto;
}
}
.full-width {
grid-column: 1 / -1;
}
.notify-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.notify-actions button {
border: none;
border-radius: 999px;
padding: 0.45rem 1.25rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(120deg, #38bdf8, #8b5cf6);
color: #0f172a;
transition: opacity 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.notify-actions .ghost-button {
background: transparent;
color: #e2e8f0;
}
.channel-health {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 1rem;
border-radius: 12px;
background: #0b1220;
border: 1px solid #1d2a44;
}
.status-pill {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid rgba(248, 250, 252, 0.3);
}
.status-pill--healthy {
border-color: #34d399;
color: #34d399;
}
.status-pill--warning {
border-color: #facc15;
color: #facc15;
}
.status-pill--error {
border-color: #f87171;
color: #f87171;
}
.channel-health__details p {
margin: 0;
font-size: 0.9rem;
}
.channel-health__details small {
color: #94a3b8;
}
.test-form h3 {
margin: 0;
font-size: 1rem;
color: #cbd5f5;
}
.test-preview {
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
background: #0b1220;
header {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
p {
margin: 0.25rem 0;
font-size: 0.9rem;
}
span {
font-weight: 600;
color: #cbd5f5;
}
.preview-body {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: #0f172a;
border-radius: 8px;
padding: 0.75rem;
}
}
.deliveries-controls {
display: flex;
justify-content: flex-start;
gap: 1rem;
}
.deliveries-controls label {
min-width: 140px;
}
.deliveries-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
thead th {
text-align: left;
font-weight: 600;
padding-bottom: 0.5rem;
color: #cbd5f5;
}
tbody td {
padding: 0.6rem 0.25rem;
border-top: 1px solid #1f2937;
}
.empty-row {
text-align: center;
color: #94a3b8;
padding: 1rem 0;
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 8px;
font-size: 0.75rem;
text-transform: uppercase;
border: 1px solid rgba(148, 163, 184, 0.5);
}
.status-badge--sent {
border-color: #34d399;
color: #34d399;
}
.status-badge--failed {
border-color: #f87171;
color: #f87171;
}
.status-badge--throttled {
border-color: #facc15;
color: #facc15;
}
@media (max-width: 720px) {
.notify-panel {
padding: 1.25rem;
}
.notify-panel__header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NOTIFY_API } from '../../core/api/notify.client';
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
import { NotifyPanelComponent } from './notify-panel.component';
describe('NotifyPanelComponent', () => {
let fixture: ComponentFixture<NotifyPanelComponent>;
let component: NotifyPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotifyPanelComponent],
providers: [
MockNotifyApiService,
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
],
}).compileComponents();
fixture = TestBed.createComponent(NotifyPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders channels from the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
const items: NodeListOf<HTMLButtonElement> =
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
expect(items.length).toBeGreaterThan(0);
});
it('persists a new rule via the mocked API', async () => {
await component.refreshAll();
fixture.detectChanges();
component.createRuleDraft();
component.ruleForm.patchValue({
name: 'Notify preview rule',
channel: component.channels()[0]?.channelId ?? '',
eventKindsText: 'scanner.report.ready',
labelsText: 'kev',
});
await component.saveRule();
fixture.detectChanges();
const ruleButtons: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
);
expect(
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
).toBeTrue();
});
it('shows a test preview after sending', async () => {
await component.refreshAll();
fixture.detectChanges();
await component.sendTestPreview();
fixture.detectChanges();
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
expect(preview).toBeTruthy();
});
});

View File

@@ -0,0 +1,642 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
NOTIFY_API,
NotifyApi,
} from '../../core/api/notify.client';
import {
ChannelHealthResponse,
ChannelTestSendResponse,
NotifyChannel,
NotifyDelivery,
NotifyDeliveriesQueryOptions,
NotifyDeliveryStatus,
NotifyRule,
NotifyRuleAction,
} from '../../core/api/notify.models';
type DeliveryFilter =
| 'all'
| 'pending'
| 'sent'
| 'failed'
| 'throttled'
| 'digested'
| 'dropped';
@Component({
selector: 'app-notify-panel',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './notify-panel.component.html',
styleUrls: ['./notify-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotifyPanelComponent implements OnInit {
private readonly api = inject<NotifyApi>(NOTIFY_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly tenantId = signal<string>('tenant-dev');
readonly channelTypes: readonly NotifyChannel['type'][] = [
'Slack',
'Teams',
'Email',
'Webhook',
'Custom',
];
readonly severityOptions = ['critical', 'high', 'medium', 'low'];
readonly channels = signal<NotifyChannel[]>([]);
readonly selectedChannelId = signal<string | null>(null);
readonly channelLoading = signal(false);
readonly channelMessage = signal<string | null>(null);
readonly channelHealth = signal<ChannelHealthResponse | null>(null);
readonly testPreview = signal<ChannelTestSendResponse | null>(null);
readonly testSending = signal(false);
readonly rules = signal<NotifyRule[]>([]);
readonly selectedRuleId = signal<string | null>(null);
readonly ruleLoading = signal(false);
readonly ruleMessage = signal<string | null>(null);
readonly deliveries = signal<NotifyDelivery[]>([]);
readonly deliveriesLoading = signal(false);
readonly deliveriesMessage = signal<string | null>(null);
readonly deliveryFilter = signal<DeliveryFilter>('all');
readonly filteredDeliveries = computed(() => {
const filter = this.deliveryFilter();
const items = this.deliveries();
if (filter === 'all') {
return items;
}
return items.filter((item) =>
item.status.toLowerCase() === filter
);
});
readonly channelForm = this.formBuilder.group({
channelId: this.formBuilder.control(''),
name: this.formBuilder.control('', {
validators: [Validators.required],
}),
displayName: this.formBuilder.control(''),
description: this.formBuilder.control(''),
type: this.formBuilder.control<NotifyChannel['type']>('Slack'),
target: this.formBuilder.control(''),
endpoint: this.formBuilder.control(''),
secretRef: this.formBuilder.control('', {
validators: [Validators.required],
}),
enabled: this.formBuilder.control(true),
labelsText: this.formBuilder.control(''),
metadataText: this.formBuilder.control(''),
});
readonly ruleForm = this.formBuilder.group({
ruleId: this.formBuilder.control(''),
name: this.formBuilder.control('', {
validators: [Validators.required],
}),
description: this.formBuilder.control(''),
enabled: this.formBuilder.control(true),
minSeverity: this.formBuilder.control('critical'),
eventKindsText: this.formBuilder.control('scanner.report.ready'),
labelsText: this.formBuilder.control('kev,critical'),
channel: this.formBuilder.control('', {
validators: [Validators.required],
}),
digest: this.formBuilder.control('instant'),
template: this.formBuilder.control('tmpl-critical'),
locale: this.formBuilder.control('en-US'),
throttleSeconds: this.formBuilder.control(300),
});
readonly testForm = this.formBuilder.group({
title: this.formBuilder.control('Policy verdict update'),
summary: this.formBuilder.control('Mock preview of Notify payload.'),
body: this.formBuilder.control(
'Sample preview body rendered by the mocked Notify API service.'
),
textBody: this.formBuilder.control(''),
target: this.formBuilder.control(''),
});
async ngOnInit(): Promise<void> {
await this.refreshAll();
}
async refreshAll(): Promise<void> {
await Promise.all([
this.loadChannels(),
this.loadRules(),
this.loadDeliveries(),
]);
}
async loadChannels(): Promise<void> {
this.channelLoading.set(true);
this.channelMessage.set(null);
try {
const channels = await firstValueFrom(this.api.listChannels());
this.channels.set(channels);
if (channels.length) {
this.tenantId.set(channels[0].tenantId);
}
if (!this.selectedChannelId() && channels.length) {
this.selectChannel(channels[0].channelId);
}
} catch (error) {
this.channelMessage.set(this.toErrorMessage(error));
} finally {
this.channelLoading.set(false);
}
}
async loadRules(): Promise<void> {
this.ruleLoading.set(true);
this.ruleMessage.set(null);
try {
const rules = await firstValueFrom(this.api.listRules());
this.rules.set(rules);
if (!this.selectedRuleId() && rules.length) {
this.selectRule(rules[0].ruleId);
}
if (!this.ruleForm.controls.channel.value && this.channels().length) {
this.ruleForm.patchValue({ channel: this.channels()[0].channelId });
}
} catch (error) {
this.ruleMessage.set(this.toErrorMessage(error));
} finally {
this.ruleLoading.set(false);
}
}
async loadDeliveries(): Promise<void> {
this.deliveriesLoading.set(true);
this.deliveriesMessage.set(null);
try {
const options: NotifyDeliveriesQueryOptions = {
status: this.mapFilterToStatus(this.deliveryFilter()),
limit: 15,
};
const response = await firstValueFrom(
this.api.listDeliveries(options)
);
this.deliveries.set([...(response.items ?? [])]);
} catch (error) {
this.deliveriesMessage.set(this.toErrorMessage(error));
} finally {
this.deliveriesLoading.set(false);
}
}
selectChannel(channelId: string): void {
const channel = this.channels().find((c) => c.channelId === channelId);
if (!channel) {
return;
}
this.selectedChannelId.set(channelId);
this.channelForm.patchValue({
channelId: channel.channelId,
name: channel.name,
displayName: channel.displayName ?? '',
description: channel.description ?? '',
type: channel.type,
target: channel.config.target ?? '',
endpoint: channel.config.endpoint ?? '',
secretRef: channel.config.secretRef,
enabled: channel.enabled,
labelsText: this.formatKeyValueMap(channel.labels),
metadataText: this.formatKeyValueMap(channel.metadata),
});
this.testPreview.set(null);
void this.loadChannelHealth(channelId);
}
selectRule(ruleId: string): void {
const rule = this.rules().find((r) => r.ruleId === ruleId);
if (!rule) {
return;
}
this.selectedRuleId.set(ruleId);
const action = rule.actions?.[0];
this.ruleForm.patchValue({
ruleId: rule.ruleId,
name: rule.name,
description: rule.description ?? '',
enabled: rule.enabled,
minSeverity: rule.match?.minSeverity ?? '',
eventKindsText: this.formatList(rule.match?.eventKinds ?? []),
labelsText: this.formatList(rule.match?.labels ?? []),
channel: action?.channel ?? this.channels()[0]?.channelId ?? '',
digest: action?.digest ?? '',
template: action?.template ?? '',
locale: action?.locale ?? '',
throttleSeconds: this.parseDuration(action?.throttle),
});
}
createChannelDraft(): void {
this.selectedChannelId.set(null);
this.channelForm.reset({
channelId: '',
name: '',
displayName: '',
description: '',
type: 'Slack',
target: '',
endpoint: '',
secretRef: '',
enabled: true,
labelsText: '',
metadataText: '',
});
this.channelHealth.set(null);
this.testPreview.set(null);
}
createRuleDraft(): void {
this.selectedRuleId.set(null);
this.ruleForm.reset({
ruleId: '',
name: '',
description: '',
enabled: true,
minSeverity: 'high',
eventKindsText: 'scanner.report.ready',
labelsText: '',
channel: this.channels()[0]?.channelId ?? '',
digest: 'instant',
template: '',
locale: 'en-US',
throttleSeconds: 0,
});
}
async saveChannel(): Promise<void> {
if (this.channelForm.invalid) {
this.channelForm.markAllAsTouched();
return;
}
this.channelLoading.set(true);
this.channelMessage.set(null);
try {
const payload = this.buildChannelPayload();
const saved = await firstValueFrom(this.api.saveChannel(payload));
await this.loadChannels();
this.selectChannel(saved.channelId);
this.channelMessage.set('Channel saved successfully.');
} catch (error) {
this.channelMessage.set(this.toErrorMessage(error));
} finally {
this.channelLoading.set(false);
}
}
async deleteChannel(): Promise<void> {
const channelId = this.selectedChannelId();
if (!channelId) {
return;
}
this.channelLoading.set(true);
this.channelMessage.set(null);
try {
await firstValueFrom(this.api.deleteChannel(channelId));
await this.loadChannels();
if (this.channels().length) {
this.selectChannel(this.channels()[0].channelId);
} else {
this.createChannelDraft();
}
this.channelMessage.set('Channel deleted.');
} catch (error) {
this.channelMessage.set(this.toErrorMessage(error));
} finally {
this.channelLoading.set(false);
}
}
async saveRule(): Promise<void> {
if (this.ruleForm.invalid) {
this.ruleForm.markAllAsTouched();
return;
}
this.ruleLoading.set(true);
this.ruleMessage.set(null);
try {
const payload = this.buildRulePayload();
const saved = await firstValueFrom(this.api.saveRule(payload));
await this.loadRules();
this.selectRule(saved.ruleId);
this.ruleMessage.set('Rule saved successfully.');
} catch (error) {
this.ruleMessage.set(this.toErrorMessage(error));
} finally {
this.ruleLoading.set(false);
}
}
async deleteRule(): Promise<void> {
const ruleId = this.selectedRuleId();
if (!ruleId) {
return;
}
this.ruleLoading.set(true);
this.ruleMessage.set(null);
try {
await firstValueFrom(this.api.deleteRule(ruleId));
await this.loadRules();
if (this.rules().length) {
this.selectRule(this.rules()[0].ruleId);
} else {
this.createRuleDraft();
}
this.ruleMessage.set('Rule deleted.');
} catch (error) {
this.ruleMessage.set(this.toErrorMessage(error));
} finally {
this.ruleLoading.set(false);
}
}
async sendTestPreview(): Promise<void> {
const channelId = this.selectedChannelId();
if (!channelId) {
this.channelMessage.set('Select a channel before running a test send.');
return;
}
this.testSending.set(true);
this.channelMessage.set(null);
try {
const payload = this.testForm.getRawValue();
const response = await firstValueFrom(
this.api.testChannel(channelId, {
target: payload.target || undefined,
title: payload.title || undefined,
summary: payload.summary || undefined,
body: payload.body || undefined,
textBody: payload.textBody || undefined,
})
);
this.testPreview.set(response);
this.channelMessage.set('Test send queued successfully.');
await this.loadDeliveries();
} catch (error) {
this.channelMessage.set(this.toErrorMessage(error));
} finally {
this.testSending.set(false);
}
}
async refreshDeliveries(): Promise<void> {
await this.loadDeliveries();
}
onDeliveryFilterChange(rawValue: string): void {
const filter = this.isDeliveryFilter(rawValue) ? rawValue : 'all';
this.deliveryFilter.set(filter);
void this.loadDeliveries();
}
trackByChannel = (_: number, item: NotifyChannel) => item.channelId;
trackByRule = (_: number, item: NotifyRule) => item.ruleId;
trackByDelivery = (_: number, item: NotifyDelivery) => item.deliveryId;
private async loadChannelHealth(channelId: string): Promise<void> {
try {
const response = await firstValueFrom(
this.api.getChannelHealth(channelId)
);
this.channelHealth.set(response);
} catch {
this.channelHealth.set(null);
}
}
private buildChannelPayload(): NotifyChannel {
const raw = this.channelForm.getRawValue();
const existing = this.channels().find((c) => c.channelId === raw.channelId);
const now = new Date().toISOString();
const channelId = raw.channelId?.trim() || this.generateId('chn');
const tenantId = existing?.tenantId ?? this.tenantId();
return {
schemaVersion: existing?.schemaVersion ?? '1.0',
channelId,
tenantId,
name: raw.name.trim(),
displayName: raw.displayName?.trim() || undefined,
description: raw.description?.trim() || undefined,
type: raw.type,
enabled: raw.enabled,
config: {
secretRef: raw.secretRef.trim(),
target: raw.target?.trim() || undefined,
endpoint: raw.endpoint?.trim() || undefined,
properties: existing?.config.properties ?? {},
limits: existing?.config.limits,
},
labels: this.parseKeyValueText(raw.labelsText),
metadata: this.parseKeyValueText(raw.metadataText),
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
createdAt: existing?.createdAt ?? now,
updatedBy: 'ui@stella-ops.local',
updatedAt: now,
};
}
private buildRulePayload(): NotifyRule {
const raw = this.ruleForm.getRawValue();
const existing = this.rules().find((r) => r.ruleId === raw.ruleId);
const now = new Date().toISOString();
const ruleId = raw.ruleId?.trim() || this.generateId('rule');
const action: NotifyRuleAction = {
actionId: existing?.actions?.[0]?.actionId ?? this.generateId('act'),
channel: raw.channel ?? this.channels()[0]?.channelId ?? '',
template: raw.template?.trim() || undefined,
digest: raw.digest?.trim() || undefined,
locale: raw.locale?.trim() || undefined,
throttle:
raw.throttleSeconds && raw.throttleSeconds > 0
? this.formatDuration(raw.throttleSeconds)
: null,
enabled: true,
metadata: existing?.actions?.[0]?.metadata ?? {},
};
return {
schemaVersion: existing?.schemaVersion ?? '1.0',
ruleId,
tenantId: existing?.tenantId ?? this.tenantId(),
name: raw.name.trim(),
description: raw.description?.trim() || undefined,
enabled: raw.enabled,
match: {
eventKinds: this.parseList(raw.eventKindsText),
labels: this.parseList(raw.labelsText),
minSeverity: raw.minSeverity?.trim() || null,
},
actions: [action],
labels: existing?.labels ?? {},
metadata: existing?.metadata ?? {},
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
createdAt: existing?.createdAt ?? now,
updatedBy: 'ui@stella-ops.local',
updatedAt: now,
};
}
private parseKeyValueText(value?: string | null): Record<string, string> {
const result: Record<string, string> = {};
if (!value) {
return result;
}
value
.split(/\r?\n|,/)
.map((entry) => entry.trim())
.filter(Boolean)
.forEach((entry) => {
const [key, ...rest] = entry.split('=');
if (!key) {
return;
}
result[key.trim()] = rest.join('=').trim();
});
return result;
}
private formatKeyValueMap(
map?: Record<string, string> | null
): string {
if (!map) {
return '';
}
return Object.entries(map)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
private parseList(value?: string | null): string[] {
if (!value) {
return [];
}
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
private formatList(items: readonly string[]): string {
if (!items?.length) {
return '';
}
return items.join('\n');
}
private parseDuration(duration?: string | null): number {
if (!duration) {
return 0;
}
if (duration.startsWith('PT')) {
const hours = extractNumber(duration, /([0-9]+)H/);
const minutes = extractNumber(duration, /([0-9]+)M/);
const seconds = extractNumber(duration, /([0-9]+)S/);
return hours * 3600 + minutes * 60 + seconds;
}
const parts = duration.split(':').map((p) => Number.parseInt(p, 10));
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return Number.parseInt(duration, 10) || 0;
}
private formatDuration(seconds: number): string {
const clamped = Math.max(0, Math.floor(seconds));
const hrs = Math.floor(clamped / 3600);
const mins = Math.floor((clamped % 3600) / 60);
const secs = clamped % 60;
let result = 'PT';
if (hrs) {
result += `${hrs}H`;
}
if (mins) {
result += `${mins}M`;
}
if (secs || result === 'PT') {
result += `${secs}S`;
}
return result;
}
private mapFilterToStatus(
filter: DeliveryFilter
): NotifyDeliveryStatus | undefined {
switch (filter) {
case 'pending':
return 'Pending';
case 'sent':
return 'Sent';
case 'failed':
return 'Failed';
case 'throttled':
return 'Throttled';
case 'digested':
return 'Digested';
case 'dropped':
return 'Dropped';
default:
return undefined;
}
}
private isDeliveryFilter(value: string): value is DeliveryFilter {
return (
value === 'all' ||
value === 'pending' ||
value === 'sent' ||
value === 'failed' ||
value === 'throttled' ||
value === 'digested' ||
value === 'dropped'
);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Operation failed. Please retry.';
}
private generateId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}
}
function extractNumber(source: string, pattern: RegExp): number {
const match = source.match(pattern);
return match ? Number.parseInt(match[1], 10) : 0;
}

View File

@@ -0,0 +1,39 @@
<section class="attestation-panel" [attr.data-status]="statusClass">
<header class="attestation-header">
<h2>Attestation</h2>
<span class="status-badge" [ngClass]="statusClass">
{{ statusLabel }}
</span>
</header>
<dl class="attestation-meta">
<div>
<dt>Rekor UUID</dt>
<dd><code>{{ attestation.uuid }}</code></dd>
</div>
<div *ngIf="attestation.index !== undefined">
<dt>Log index</dt>
<dd>{{ attestation.index }}</dd>
</div>
<div *ngIf="attestation.logUrl">
<dt>Log URL</dt>
<dd>
<a
[href]="attestation.logUrl"
rel="noopener noreferrer"
target="_blank"
>
{{ attestation.logUrl }}
</a>
</dd>
</div>
<div *ngIf="attestation.checkedAt">
<dt>Last checked</dt>
<dd>{{ attestation.checkedAt }}</dd>
</div>
<div *ngIf="attestation.statusMessage">
<dt>Details</dt>
<dd>{{ attestation.statusMessage }}</dd>
</div>
</dl>
</section>

View File

@@ -0,0 +1,75 @@
.attestation-panel {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
color: #f8fafc;
display: grid;
gap: 1rem;
}
.attestation-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.attestation-header h2 {
margin: 0;
font-size: 1.125rem;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.verified {
background-color: rgba(34, 197, 94, 0.2);
color: #34d399;
}
.status-badge.pending {
background-color: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.status-badge.failed {
background-color: rgba(248, 113, 113, 0.2);
color: #f87171;
}
.attestation-meta {
margin: 0;
display: grid;
gap: 0.75rem;
}
.attestation-meta div {
display: grid;
gap: 0.25rem;
}
.attestation-meta dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
}
.attestation-meta dd {
margin: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', monospace;
word-break: break-word;
}
.attestation-meta a {
color: #60a5fa;
text-decoration: underline;
}

View File

@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
describe('ScanAttestationPanelComponent', () => {
let component: ScanAttestationPanelComponent;
let fixture: ComponentFixture<ScanAttestationPanelComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScanAttestationPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScanAttestationPanelComponent);
component = fixture.componentInstance;
});
it('renders verified attestation details', () => {
component.attestation = {
uuid: '1234',
status: 'verified',
index: 42,
logUrl: 'https://rekor.example',
checkedAt: '2025-10-23T10:05:00Z',
statusMessage: 'Rekor transparency log inclusion proof verified.',
};
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
'Verified'
);
expect(element.textContent).toContain('1234');
expect(element.textContent).toContain('42');
expect(element.textContent).toContain('https://rekor.example');
});
it('renders failure message when attestation verification fails', () => {
component.attestation = {
uuid: 'abcd',
status: 'failed',
statusMessage: 'Verification failed: inclusion proof mismatch.',
};
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
'Verification failed'
);
expect(element.textContent).toContain(
'Verification failed: inclusion proof mismatch.'
);
});
});

View File

@@ -0,0 +1,42 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Input,
} from '@angular/core';
import {
ScanAttestationStatus,
ScanAttestationStatusKind,
} from '../../core/api/scanner.models';
@Component({
selector: 'app-scan-attestation-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './scan-attestation-panel.component.html',
styleUrls: ['./scan-attestation-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScanAttestationPanelComponent {
@Input({ required: true }) attestation!: ScanAttestationStatus;
get statusLabel(): string {
return this.toStatusLabel(this.attestation?.status);
}
get statusClass(): string {
return this.attestation?.status ?? 'pending';
}
private toStatusLabel(status: ScanAttestationStatusKind | undefined): string {
switch (status) {
case 'verified':
return 'Verified';
case 'failed':
return 'Verification failed';
case 'pending':
default:
return 'Pending verification';
}
}
}

View File

@@ -0,0 +1,52 @@
<section class="scan-detail">
<header class="scan-detail__header">
<h1>Scan Detail</h1>
<div class="scenario-toggle" role="group" aria-label="Scenario selector">
<button
type="button"
class="scenario-button"
[class.active]="scenario() === 'verified'"
(click)="onSelectScenario('verified')"
data-scenario="verified"
>
Verified
</button>
<button
type="button"
class="scenario-button"
[class.active]="scenario() === 'failed'"
(click)="onSelectScenario('failed')"
data-scenario="failed"
>
Failure
</button>
</div>
</header>
<section class="scan-summary">
<h2>Image</h2>
<dl>
<div>
<dt>Scan ID</dt>
<dd>{{ scan().scanId }}</dd>
</div>
<div>
<dt>Image digest</dt>
<dd><code>{{ scan().imageDigest }}</code></dd>
</div>
<div>
<dt>Completed at</dt>
<dd>{{ scan().completedAt }}</dd>
</div>
</dl>
</section>
<app-scan-attestation-panel
*ngIf="scan().attestation as attestation"
[attestation]="attestation"
/>
<p *ngIf="!scan().attestation" class="attestation-empty">
No attestation has been recorded for this scan.
</p>
</section>

View File

@@ -0,0 +1,79 @@
.scan-detail {
display: grid;
gap: 1.5rem;
padding: 1.5rem;
color: #e2e8f0;
background: #0f172a;
min-height: calc(100vh - 120px);
}
.scan-detail__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.scan-detail__header h1 {
margin: 0;
font-size: 1.5rem;
}
.scenario-toggle {
display: inline-flex;
border: 1px solid #1f2933;
border-radius: 999px;
overflow: hidden;
}
.scenario-button {
background: transparent;
color: inherit;
border: none;
padding: 0.5rem 1.25rem;
cursor: pointer;
font-size: 0.9rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.scenario-button.active {
background: #1d4ed8;
color: #f8fafc;
}
.scan-summary {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
}
.scan-summary h2 {
margin: 0 0 0.75rem 0;
font-size: 1.125rem;
}
.scan-summary dl {
margin: 0;
display: grid;
gap: 0.75rem;
}
.scan-summary dt {
font-size: 0.75rem;
text-transform: uppercase;
color: #94a3b8;
}
.scan-summary dd {
margin: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', monospace;
word-break: break-word;
}
.attestation-empty {
font-style: italic;
color: #94a3b8;
}

View File

@@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ScanDetailPageComponent } from './scan-detail-page.component';
import {
scanDetailWithFailedAttestation,
scanDetailWithVerifiedAttestation,
} from '../../testing/scan-fixtures';
describe('ScanDetailPageComponent', () => {
let fixture: ComponentFixture<ScanDetailPageComponent>;
let component: ScanDetailPageComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule, ScanDetailPageComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScanDetailPageComponent);
component = fixture.componentInstance;
});
it('shows the verified attestation scenario by default', () => {
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.textContent).toContain(
scanDetailWithVerifiedAttestation.attestation?.uuid ?? ''
);
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
'Verified'
);
});
it('switches to failure scenario when toggle is clicked', () => {
fixture.detectChanges();
const failureButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector('[data-scenario="failed"]');
failureButton?.click();
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.textContent).toContain(
scanDetailWithFailedAttestation.attestation?.uuid ?? ''
);
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
'Verification failed'
);
});
});

View File

@@ -0,0 +1,62 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
import { ScanDetail } from '../../core/api/scanner.models';
import {
scanDetailWithFailedAttestation,
scanDetailWithVerifiedAttestation,
} from '../../testing/scan-fixtures';
type Scenario = 'verified' | 'failed';
const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
verified: scanDetailWithVerifiedAttestation,
failed: scanDetailWithFailedAttestation,
};
@Component({
selector: 'app-scan-detail-page',
standalone: true,
imports: [CommonModule, ScanAttestationPanelComponent],
templateUrl: './scan-detail-page.component.html',
styleUrls: ['./scan-detail-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScanDetailPageComponent {
private readonly route = inject(ActivatedRoute);
readonly scenario = signal<Scenario>('verified');
readonly scan = computed<ScanDetail>(() => {
const current = this.scenario();
return SCENARIO_MAP[current];
});
constructor() {
const routeScenario =
(this.route.snapshot.queryParamMap.get('scenario') as Scenario | null) ??
null;
if (routeScenario && routeScenario in SCENARIO_MAP) {
this.scenario.set(routeScenario);
return;
}
const scanId = this.route.snapshot.paramMap.get('scanId');
if (scanId === scanDetailWithFailedAttestation.scanId) {
this.scenario.set('failed');
} else {
this.scenario.set('verified');
}
}
onSelectScenario(next: Scenario): void {
this.scenario.set(next);
}
}

View File

@@ -0,0 +1,108 @@
<section class="settings-card" aria-labelledby="trivy-db-settings-heading">
<header class="card-header">
<div>
<h1 id="trivy-db-settings-heading">Trivy DB export settings</h1>
<p class="card-subtitle">
Configure export behaviour for downstream mirrors. Changes apply on the next run.
</p>
</div>
<div class="header-actions">
<button
type="button"
class="secondary"
(click)="loadSettings()"
[disabled]="isBusy"
>
Refresh
</button>
</div>
</header>
<form [formGroup]="form" (ngSubmit)="onSave()" class="settings-form">
<fieldset [disabled]="isBusy">
<legend class="sr-only">Export toggles</legend>
<label class="toggle">
<input type="checkbox" formControlName="publishFull" />
<span class="toggle-label">
Publish full database exports
<span class="toggle-hint">
Required for first-time consumers or when they fall behind.
</span>
</span>
</label>
<label class="toggle">
<input type="checkbox" formControlName="publishDelta" />
<span class="toggle-label">
Publish delta updates
<span class="toggle-hint">
Incremental exports reduce bandwidth between full releases.
</span>
</span>
</label>
<label class="toggle">
<input type="checkbox" formControlName="includeFull" />
<span class="toggle-label">
Include full archive in offline bundle
<span class="toggle-hint">
Bundles deliver everything required for air-gapped sites.
</span>
</span>
</label>
<label class="toggle">
<input type="checkbox" formControlName="includeDelta" />
<span class="toggle-label">
Include delta archive in offline bundle
<span class="toggle-hint">
Provides faster incremental sync within offline kits.
</span>
</span>
</label>
</fieldset>
<div class="form-actions">
<button type="submit" class="primary" [disabled]="isBusy">
Save changes
</button>
<button
type="button"
class="primary outline"
(click)="onRunExport()"
[disabled]="isBusy"
>
Run export now
</button>
</div>
</form>
<div
*ngIf="message() as note"
class="status"
[class.status-success]="status() === 'success'"
[class.status-error]="status() === 'error'"
role="status"
>
{{ note }}
</div>
<section *ngIf="lastRun() as run" class="last-run" aria-live="polite">
<h2>Last triggered run</h2>
<dl>
<div>
<dt>Export ID</dt>
<dd>{{ run.exportId }}</dd>
</div>
<div>
<dt>Triggered</dt>
<dd>{{ run.triggeredAt | date : 'yyyy-MM-dd HH:mm:ss \'UTC\'' }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{{ run.status ?? 'pending' }}</dd>
</div>
</dl>
</section>
</section>

View File

@@ -0,0 +1,230 @@
:host {
display: block;
}
.settings-card {
background-color: #ffffff;
border-radius: 16px;
padding: 1.75rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.card-subtitle {
margin: 0.25rem 0 0;
color: #475569;
font-size: 0.95rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.toggle {
display: flex;
gap: 0.75rem;
align-items: flex-start;
background-color: #f8fafc;
border-radius: 12px;
padding: 0.9rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.4);
transition: border-color 0.2s ease, background-color 0.2s ease;
&:focus-within,
&:hover {
border-color: rgba(67, 40, 183, 0.5);
background-color: #eef2ff;
}
input[type='checkbox'] {
margin-top: 0.2rem;
width: 1.1rem;
height: 1.1rem;
}
}
.toggle-label {
font-weight: 600;
color: #0f172a;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.toggle-hint {
font-weight: 400;
font-size: 0.9rem;
color: #475569;
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
button {
appearance: none;
border: none;
border-radius: 9999px;
padding: 0.55rem 1.35rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
button.primary {
color: #ffffff;
background: linear-gradient(135deg, #4338ca 0%, #7c3aed 100%);
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.25);
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(79, 70, 229, 0.35);
}
}
button.primary.outline {
background: transparent;
color: #4338ca;
border: 1px solid rgba(79, 70, 229, 0.4);
box-shadow: none;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background: rgba(79, 70, 229, 0.12);
}
}
button.secondary {
background: rgba(148, 163, 184, 0.2);
color: #0f172a;
border: 1px solid rgba(148, 163, 184, 0.4);
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background: rgba(148, 163, 184, 0.35);
}
}
.status {
padding: 0.85rem 1rem;
border-radius: 12px;
font-size: 0.95rem;
background-color: rgba(15, 23, 42, 0.05);
color: #0f172a;
}
.status-success {
background-color: rgba(16, 185, 129, 0.15);
color: #065f46;
}
.status-error {
background-color: rgba(239, 68, 68, 0.15);
color: #991b1b;
}
.last-run {
border-top: 1px solid rgba(148, 163, 184, 0.4);
padding-top: 1rem;
h2 {
font-size: 1.05rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: #0f172a;
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem 1.5rem;
div {
background: #f8fafc;
border-radius: 12px;
padding: 0.85rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.35);
}
dt {
font-weight: 600;
color: #334155;
margin-bottom: 0.3rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
dd {
margin: 0;
color: #0f172a;
font-size: 0.95rem;
word-break: break-word;
}
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
@media (max-width: 640px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: flex-end;
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
text-align: center;
}
}

View File

@@ -0,0 +1,94 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import {
ConcelierExporterClient,
TrivyDbRunResponseDto,
TrivyDbSettingsDto,
} from '../../core/api/concelier-exporter.client';
import { TrivyDbSettingsPageComponent } from './trivy-db-settings-page.component';
describe('TrivyDbSettingsPageComponent', () => {
let fixture: ComponentFixture<TrivyDbSettingsPageComponent>;
let component: TrivyDbSettingsPageComponent;
let client: jasmine.SpyObj<ConcelierExporterClient>;
const settings: TrivyDbSettingsDto = {
publishFull: true,
publishDelta: false,
includeFull: true,
includeDelta: false,
};
beforeEach(async () => {
client = jasmine.createSpyObj<ConcelierExporterClient>(
'ConcelierExporterClient',
['getTrivyDbSettings', 'updateTrivyDbSettings', 'runTrivyDbExport']
);
client.getTrivyDbSettings.and.returnValue(of(settings));
client.updateTrivyDbSettings.and.returnValue(of(settings));
client.runTrivyDbExport.and.returnValue(
of<TrivyDbRunResponseDto>({
exportId: 'exp-1',
triggeredAt: '2025-10-21T12:00:00Z',
status: 'queued',
})
);
await TestBed.configureTestingModule({
imports: [TrivyDbSettingsPageComponent],
providers: [{ provide: ConcelierExporterClient, useValue: client }],
}).compileComponents();
fixture = TestBed.createComponent(TrivyDbSettingsPageComponent);
component = fixture.componentInstance;
});
it('loads existing settings on init', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(client.getTrivyDbSettings).toHaveBeenCalled();
expect(component.form.value).toEqual(settings);
}));
it('saves settings when submit is triggered', fakeAsync(async () => {
fixture.detectChanges();
tick();
await component.onSave();
expect(client.updateTrivyDbSettings).toHaveBeenCalledWith(settings);
expect(component.status()).toBe('success');
}));
it('records error state when load fails', fakeAsync(() => {
client.getTrivyDbSettings.and.returnValue(
throwError(() => new Error('load failed'))
);
fixture = TestBed.createComponent(TrivyDbSettingsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
tick();
expect(component.status()).toBe('error');
expect(component.message()).toContain('load failed');
}));
it('triggers export run after saving overrides', fakeAsync(async () => {
fixture.detectChanges();
tick();
await component.onRunExport();
expect(client.updateTrivyDbSettings).toHaveBeenCalled();
expect(client.runTrivyDbExport).toHaveBeenCalled();
expect(component.lastRun()).toEqual({
exportId: 'exp-1',
triggeredAt: '2025-10-21T12:00:00Z',
status: 'queued',
});
}));
});

View File

@@ -0,0 +1,135 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
ConcelierExporterClient,
TrivyDbRunResponseDto,
TrivyDbSettingsDto,
} from '../../core/api/concelier-exporter.client';
type StatusKind = 'idle' | 'loading' | 'saving' | 'running' | 'success' | 'error';
type TrivyDbSettingsFormValue = TrivyDbSettingsDto;
@Component({
selector: 'app-trivy-db-settings-page',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './trivy-db-settings-page.component.html',
styleUrls: ['./trivy-db-settings-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TrivyDbSettingsPageComponent implements OnInit {
private readonly client = inject(ConcelierExporterClient);
private readonly formBuilder = inject(NonNullableFormBuilder);
readonly status = signal<StatusKind>('idle');
readonly message = signal<string | null>(null);
readonly lastRun = signal<TrivyDbRunResponseDto | null>(null);
readonly form = this.formBuilder.group<TrivyDbSettingsFormValue>({
publishFull: true,
publishDelta: true,
includeFull: true,
includeDelta: true,
});
ngOnInit(): void {
void this.loadSettings();
}
async loadSettings(): Promise<void> {
this.status.set('loading');
this.message.set(null);
try {
const settings: TrivyDbSettingsDto = await firstValueFrom(
this.client.getTrivyDbSettings()
);
this.form.patchValue(settings);
this.status.set('idle');
} catch (error) {
this.status.set('error');
this.message.set(
error instanceof Error
? error.message
: 'Failed to load Trivy DB settings.'
);
}
}
async onSave(): Promise<void> {
this.status.set('saving');
this.message.set(null);
try {
const payload = this.buildPayload();
const updated: TrivyDbSettingsDto = await firstValueFrom(
this.client.updateTrivyDbSettings(payload)
);
this.form.patchValue(updated);
this.status.set('success');
this.message.set('Settings saved successfully.');
} catch (error) {
this.status.set('error');
this.message.set(
error instanceof Error
? error.message
: 'Unable to save settings. Please retry.'
);
}
}
async onRunExport(): Promise<void> {
this.status.set('running');
this.message.set(null);
try {
const payload = this.buildPayload();
// Persist overrides before triggering a run, ensuring parity.
await firstValueFrom(this.client.updateTrivyDbSettings(payload));
const response: TrivyDbRunResponseDto = await firstValueFrom(
this.client.runTrivyDbExport(payload)
);
this.lastRun.set(response);
this.status.set('success');
const formatted = new Date(response.triggeredAt).toISOString();
this.message.set(
`Export run ${response.exportId} triggered at ${formatted}.`
);
} catch (error) {
this.status.set('error');
this.message.set(
error instanceof Error
? error.message
: 'Failed to trigger export run. Please retry.'
);
}
}
get isBusy(): boolean {
const state = this.status();
return state === 'loading' || state === 'saving' || state === 'running';
}
private buildPayload(): TrivyDbSettingsDto {
const raw = this.form.getRawValue();
return {
publishFull: !!raw.publishFull,
publishDelta: !!raw.publishDelta,
includeFull: !!raw.includeFull,
includeDelta: !!raw.includeDelta,
};
}
}

View File

@@ -0,0 +1,290 @@
import { Injectable, signal } from '@angular/core';
import { defer, Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { NotifyApi } from '../core/api/notify.client';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
ChannelTestSendResponse,
ChannelHealthStatus,
NotifyChannel,
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyDelivery,
NotifyDeliveryRendered,
NotifyRule,
} from '../core/api/notify.models';
import {
inferHealthStatus,
mockNotifyChannels,
mockNotifyDeliveries,
mockNotifyRules,
mockNotifyTenant,
} from './notify-fixtures';
const LATENCY_MS = 140;
@Injectable({ providedIn: 'root' })
export class MockNotifyApiService implements NotifyApi {
private readonly channels = signal<NotifyChannel[]>(
clone(mockNotifyChannels)
);
private readonly rules = signal<NotifyRule[]>(clone(mockNotifyRules));
private readonly deliveries = signal<NotifyDelivery[]>(
clone(mockNotifyDeliveries)
);
listChannels(): Observable<NotifyChannel[]> {
return this.simulate(() => this.channels());
}
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
const next = this.enrichChannel(channel);
this.channels.update((items) => upsertById(items, next, (c) => c.channelId));
return this.simulate(() => next);
}
deleteChannel(channelId: string): Observable<void> {
this.channels.update((items) => items.filter((c) => c.channelId !== channelId));
return this.simulate(() => undefined);
}
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
const channel = this.channels().find((c) => c.channelId === channelId);
const now = new Date().toISOString();
const status: ChannelHealthStatus = channel
? inferHealthStatus(channel.enabled, !!channel.config.target)
: 'Unhealthy';
const response: ChannelHealthResponse = {
tenantId: mockNotifyTenant,
channelId,
status,
message:
status === 'Healthy'
? 'Channel configuration validated.'
: status === 'Degraded'
? 'Channel disabled. Enable to resume deliveries.'
: 'Channel is missing a destination target or endpoint.',
checkedAt: now,
traceId: this.traceId(),
metadata: channel?.metadata ?? {},
};
return this.simulate(() => response, 90);
}
testChannel(
channelId: string,
payload: ChannelTestSendRequest
): Observable<ChannelTestSendResponse> {
const channel = this.channels().find((c) => c.channelId === channelId);
const preview: NotifyDeliveryRendered = {
channelType: channel?.type ?? 'Slack',
format: channel?.type === 'Email' ? 'Email' : 'Slack',
target:
payload.target ?? channel?.config.target ?? channel?.config.endpoint ?? 'demo@stella-ops.org',
title: payload.title ?? 'Notify preview — policy verdict change',
body:
payload.body ??
'Sample preview payload emitted by the mocked Notify API integration.',
summary: payload.summary ?? 'Mock delivery queued.',
textBody: payload.textBody,
locale: payload.locale ?? 'en-US',
attachments: payload.attachments ?? [],
};
const response: ChannelTestSendResponse = {
tenantId: mockNotifyTenant,
channelId,
preview,
queuedAt: new Date().toISOString(),
traceId: this.traceId(),
metadata: {
source: 'mock-service',
},
};
this.appendDeliveryFromPreview(channelId, preview);
return this.simulate(() => response, 180);
}
listRules(): Observable<NotifyRule[]> {
return this.simulate(() => this.rules());
}
saveRule(rule: NotifyRule): Observable<NotifyRule> {
const next = this.enrichRule(rule);
this.rules.update((items) => upsertById(items, next, (r) => r.ruleId));
return this.simulate(() => next);
}
deleteRule(ruleId: string): Observable<void> {
this.rules.update((items) => items.filter((rule) => rule.ruleId !== ruleId));
return this.simulate(() => undefined);
}
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse> {
const filtered = this.filterDeliveries(options);
const payload: NotifyDeliveriesResponse = {
items: filtered,
continuationToken: null,
count: filtered.length,
};
return this.simulate(() => payload);
}
private enrichChannel(channel: NotifyChannel): NotifyChannel {
const now = new Date().toISOString();
const current = this.channels().find((c) => c.channelId === channel.channelId);
return {
schemaVersion: channel.schemaVersion ?? current?.schemaVersion ?? '1.0',
channelId: channel.channelId || this.randomId('chn'),
tenantId: channel.tenantId || mockNotifyTenant,
name: channel.name,
displayName: channel.displayName,
description: channel.description,
type: channel.type,
enabled: channel.enabled,
config: {
...channel.config,
properties: channel.config.properties ?? current?.config.properties ?? {},
},
labels: channel.labels ?? current?.labels ?? {},
metadata: channel.metadata ?? current?.metadata ?? {},
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
createdAt: current?.createdAt ?? now,
updatedBy: 'ui@stella-ops.org',
updatedAt: now,
};
}
private enrichRule(rule: NotifyRule): NotifyRule {
const now = new Date().toISOString();
const current = this.rules().find((r) => r.ruleId === rule.ruleId);
return {
schemaVersion: rule.schemaVersion ?? current?.schemaVersion ?? '1.0',
ruleId: rule.ruleId || this.randomId('rule'),
tenantId: rule.tenantId || mockNotifyTenant,
name: rule.name,
description: rule.description,
enabled: rule.enabled,
match: rule.match,
actions: rule.actions?.length
? rule.actions
: current?.actions ?? [],
labels: rule.labels ?? current?.labels ?? {},
metadata: rule.metadata ?? current?.metadata ?? {},
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
createdAt: current?.createdAt ?? now,
updatedBy: 'ui@stella-ops.org',
updatedAt: now,
};
}
private appendDeliveryFromPreview(
channelId: string,
preview: NotifyDeliveryRendered
): void {
const now = new Date().toISOString();
const delivery: NotifyDelivery = {
deliveryId: this.randomId('dlv'),
tenantId: mockNotifyTenant,
ruleId: 'rule-critical-soc',
actionId: 'act-slack-critical',
eventId: cryptoRandomUuid(),
kind: 'notify.preview',
status: 'Sent',
statusReason: 'Preview enqueued (mock)',
rendered: preview,
attempts: [
{
timestamp: now,
status: 'Enqueued',
statusCode: 202,
},
{
timestamp: now,
status: 'Succeeded',
statusCode: 200,
},
],
metadata: {
previewChannel: channelId,
},
createdAt: now,
sentAt: now,
completedAt: now,
};
this.deliveries.update((items) => [delivery, ...items].slice(0, 20));
}
private filterDeliveries(
options?: NotifyDeliveriesQueryOptions
): NotifyDelivery[] {
const source = this.deliveries();
const since = options?.since ? Date.parse(options.since) : null;
const status = options?.status;
return source
.filter((item) => {
const matchStatus = status ? item.status === status : true;
const matchSince = since ? Date.parse(item.createdAt) >= since : true;
return matchStatus && matchSince;
})
.slice(0, options?.limit ?? 15);
}
private simulate<T>(factory: () => T, ms: number = LATENCY_MS): Observable<T> {
return defer(() => of(clone(factory()))).pipe(delay(ms));
}
private randomId(prefix: string): string {
const raw = cryptoRandomUuid().replace(/-/g, '').slice(0, 12);
return `${prefix}-${raw}`;
}
private traceId(): string {
return `trace-${cryptoRandomUuid()}`;
}
}
function upsertById<T>(
collection: readonly T[],
entity: T,
selector: (item: T) => string
): T[] {
const id = selector(entity);
const next = [...collection];
const index = next.findIndex((item) => selector(item) === id);
if (index >= 0) {
next[index] = entity;
} else {
next.unshift(entity);
}
return next;
}
function clone<T>(value: T): T {
if (typeof structuredClone === 'function') {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function cryptoRandomUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
return template.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,257 @@
import {
ChannelHealthStatus,
NotifyChannel,
NotifyDelivery,
NotifyDeliveryAttemptStatus,
NotifyDeliveryStatus,
NotifyRule,
} from '../core/api/notify.models';
export const mockNotifyTenant = 'tenant-dev';
export const mockNotifyChannels: NotifyChannel[] = [
{
channelId: 'chn-slack-soc',
tenantId: mockNotifyTenant,
name: 'slack-soc',
displayName: 'Slack · SOC',
description: 'Critical scanner verdicts routed to the SOC war room.',
type: 'Slack',
enabled: true,
config: {
secretRef: 'ref://notify/slack/soc-token',
target: '#stellaops-soc',
properties: {
emoji: ':rotating_light:',
unfurl: 'false',
},
},
labels: {
tier: 'critical',
region: 'global',
},
metadata: {
workspace: 'stellaops',
},
createdBy: 'ops@stella-ops.org',
createdAt: '2025-10-10T08:12:00Z',
updatedBy: 'ops@stella-ops.org',
updatedAt: '2025-10-23T11:05:00Z',
},
{
channelId: 'chn-email-comms',
tenantId: mockNotifyTenant,
name: 'email-compliance',
displayName: 'Email · Compliance Digest',
description: 'Hourly compliance digest for licensing/secrets alerts.',
type: 'Email',
enabled: true,
config: {
secretRef: 'ref://notify/smtp/compliance',
target: 'compliance@stella-ops.org',
},
labels: {
cadence: 'hourly',
},
metadata: {
smtpProfile: 'smtp.internal',
},
createdBy: 'legal@stella-ops.org',
createdAt: '2025-10-08T14:31:00Z',
updatedBy: 'legal@stella-ops.org',
updatedAt: '2025-10-20T09:44:00Z',
},
{
channelId: 'chn-webhook-intake',
tenantId: mockNotifyTenant,
name: 'webhook-opsbridge',
displayName: 'Webhook · OpsBridge',
description: 'Bridges Notify events into OpsBridge for automation.',
type: 'Webhook',
enabled: false,
config: {
secretRef: 'ref://notify/webhook/signing',
endpoint: 'https://opsbridge.internal/hooks/notify',
},
labels: {
env: 'staging',
},
metadata: {
signature: 'ed25519',
},
createdBy: 'platform@stella-ops.org',
createdAt: '2025-10-05T12:01:00Z',
updatedBy: 'platform@stella-ops.org',
updatedAt: '2025-10-18T17:22:00Z',
},
];
export const mockNotifyRules: NotifyRule[] = [
{
ruleId: 'rule-critical-soc',
tenantId: mockNotifyTenant,
name: 'Critical scanner verdicts',
description:
'Route KEV-tagged critical findings to SOC Slack with zero delay.',
enabled: true,
match: {
eventKinds: ['scanner.report.ready'],
labels: ['kev', 'critical'],
minSeverity: 'critical',
verdicts: ['block', 'escalate'],
kevOnly: true,
},
actions: [
{
actionId: 'act-slack-critical',
channel: 'chn-slack-soc',
template: 'tmpl-critical',
digest: 'instant',
throttle: 'PT300S',
locale: 'en-US',
enabled: true,
metadata: {
priority: 'p1',
},
},
],
labels: {
owner: 'soc',
},
metadata: {
revision: '12',
},
createdBy: 'soc@stella-ops.org',
createdAt: '2025-10-12T10:02:00Z',
updatedBy: 'soc@stella-ops.org',
updatedAt: '2025-10-23T15:44:00Z',
},
{
ruleId: 'rule-digest-compliance',
tenantId: mockNotifyTenant,
name: 'Compliance hourly digest',
description: 'Summarise licensing + secret alerts once per hour.',
enabled: true,
match: {
eventKinds: ['scanner.scan.completed', 'scanner.report.ready'],
labels: ['compliance'],
minSeverity: 'medium',
kevOnly: false,
vex: {
includeAcceptedJustifications: true,
includeRejectedJustifications: false,
includeUnknownJustifications: true,
justificationKinds: ['exploitable', 'component_not_present'],
},
},
actions: [
{
actionId: 'act-email-compliance',
channel: 'chn-email-comms',
digest: '1h',
throttle: 'PT1H',
enabled: true,
metadata: {
layout: 'digest',
},
},
],
labels: {
owner: 'compliance',
},
metadata: {
frequency: 'hourly',
},
createdBy: 'compliance@stella-ops.org',
createdAt: '2025-10-09T06:15:00Z',
updatedBy: 'compliance@stella-ops.org',
updatedAt: '2025-10-21T19:45:00Z',
},
];
const deliveryStatuses: NotifyDeliveryStatus[] = [
'Sent',
'Failed',
'Throttled',
];
export const mockNotifyDeliveries: NotifyDelivery[] = deliveryStatuses.map(
(status, index) => {
const now = new Date('2025-10-24T12:00:00Z').getTime();
const created = new Date(now - index * 20 * 60 * 1000).toISOString();
const attemptsStatus: NotifyDeliveryAttemptStatus =
status === 'Sent' ? 'Succeeded' : status === 'Failed' ? 'Failed' : 'Throttled';
return {
deliveryId: `dlv-${index + 1}`,
tenantId: mockNotifyTenant,
ruleId: index === 0 ? 'rule-critical-soc' : 'rule-digest-compliance',
actionId: index === 0 ? 'act-slack-critical' : 'act-email-compliance',
eventId: `00000000-0000-0000-0000-${(index + 1)
.toString()
.padStart(12, '0')}`,
kind: index === 0 ? 'scanner.report.ready' : 'scanner.scan.completed',
status,
statusReason:
status === 'Sent'
? 'Delivered'
: status === 'Failed'
? 'Channel timeout (Slack API)'
: 'Rule throttled (digest window).',
rendered: {
channelType: index === 0 ? 'Slack' : 'Email',
format: index === 0 ? 'Slack' : 'Email',
target: index === 0 ? '#stellaops-soc' : 'compliance@stella-ops.org',
title:
index === 0
? 'Critical CVE flagged for registry.git.stella-ops.org'
: 'Hourly compliance digest (#23)',
body:
index === 0
? 'KEV CVE-2025-1234 detected in ubuntu:24.04. Rescan triggered.'
: '3 findings require compliance review. See attached report.',
summary: index === 0 ? 'Immediate attention required.' : 'Digest only.',
locale: 'en-US',
attachments: index === 0 ? [] : ['https://scanner.local/reports/digest-23'],
},
attempts: [
{
timestamp: created,
status: 'Sending',
statusCode: 202,
},
{
timestamp: created,
status: attemptsStatus,
statusCode: status === 'Sent' ? 200 : 429,
reason:
status === 'Failed'
? 'Slack API returned 504'
: status === 'Throttled'
? 'Digest window open'
: undefined,
},
],
metadata: {
batch: `window-${index + 1}`,
},
createdAt: created,
sentAt: created,
completedAt: created,
} satisfies NotifyDelivery;
}
);
export function inferHealthStatus(
enabled: boolean,
hasTarget: boolean
): ChannelHealthStatus {
if (!hasTarget) {
return 'Unhealthy';
}
if (!enabled) {
return 'Degraded';
}
return 'Healthy';
}

View File

@@ -0,0 +1,54 @@
import { getPolicyPreviewFixture, getPolicyReportFixture } from './policy-fixtures';
describe('policy fixtures', () => {
it('returns fresh clones for preview data', () => {
const first = getPolicyPreviewFixture();
const second = getPolicyPreviewFixture();
expect(first).not.toBe(second);
expect(first.previewRequest).not.toBe(second.previewRequest);
expect(first.previewResponse.diffs).not.toBe(second.previewResponse.diffs);
});
it('exposes required policy preview fields', () => {
const { previewRequest, previewResponse } = getPolicyPreviewFixture();
expect(previewRequest.imageDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(Array.isArray(previewRequest.findings)).toBeTrue();
expect(previewRequest.findings.length).toBeGreaterThan(0);
expect(previewResponse.success).toBeTrue();
expect(previewResponse.policyDigest).toMatch(/^[0-9a-f]{64}$/);
expect(previewResponse.diffs.length).toBeGreaterThan(0);
const diff = previewResponse.diffs[0];
expect(diff.projected.confidenceBand).toBeDefined();
expect(diff.projected.unknownConfidence).toBeGreaterThan(0);
expect(diff.projected.reachability).toBeDefined();
});
it('aligns preview and report fixtures', () => {
const preview = getPolicyPreviewFixture();
const { reportResponse } = getPolicyReportFixture();
expect(reportResponse.report.policy.digest).toEqual(
preview.previewResponse.policyDigest
);
expect(reportResponse.report.verdicts.length).toEqual(
reportResponse.report.summary.total
);
expect(reportResponse.report.verdicts.length).toBeGreaterThan(0);
expect(
reportResponse.report.verdicts.some(
(verdict) => verdict.confidenceBand != null
)
).toBeTrue();
});
it('provides DSSE metadata for report fixture', () => {
const { reportResponse } = getPolicyReportFixture();
expect(reportResponse.dsse).toBeDefined();
expect(reportResponse.dsse?.payloadType).toBe('application/vnd.stellaops.report+json');
expect(reportResponse.dsse?.signatures?.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,23 @@
import previewSample from '../../../../../samples/policy/policy-preview-unknown.json';
import reportSample from '../../../../../samples/policy/policy-report-unknown.json';
import {
PolicyPreviewSample,
PolicyReportSample,
} from '../core/api/policy-preview.models';
const previewFixture: PolicyPreviewSample =
previewSample as unknown as PolicyPreviewSample;
const reportFixture: PolicyReportSample =
reportSample as unknown as PolicyReportSample;
export function getPolicyPreviewFixture(): PolicyPreviewSample {
return clone(previewFixture);
}
export function getPolicyReportFixture(): PolicyReportSample {
return clone(reportFixture);
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}

View File

@@ -0,0 +1,30 @@
import { ScanDetail } from '../core/api/scanner.models';
export const scanDetailWithVerifiedAttestation: ScanDetail = {
scanId: 'scan-verified-001',
imageDigest:
'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071',
completedAt: '2025-10-20T18:22:04Z',
attestation: {
uuid: '018ed91c-9b64-7edc-b9ac-0bada2f8d501',
index: 412398,
logUrl: 'https://rekor.sigstore.dev',
status: 'verified',
checkedAt: '2025-10-23T12:04:52Z',
statusMessage: 'Rekor transparency log inclusion proof verified.',
},
};
export const scanDetailWithFailedAttestation: ScanDetail = {
scanId: 'scan-failed-002',
imageDigest:
'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0',
completedAt: '2025-10-19T07:14:33Z',
attestation: {
uuid: '018ed91c-ffff-4882-9955-0027c0bbb090',
status: 'failed',
checkedAt: '2025-10-23T09:18:11Z',
statusMessage:
'Verification failed: inclusion proof leaf hash mismatch at depth 4.',
},
};

View File

@@ -0,0 +1,26 @@
{
"authority": {
"issuer": "https://authority.local",
"clientId": "stellaops-ui",
"authorizeEndpoint": "https://authority.local/connect/authorize",
"tokenEndpoint": "https://authority.local/connect/token",
"logoutEndpoint": "https://authority.local/connect/logout",
"redirectUri": "http://localhost:4400/auth/callback",
"postLogoutRedirectUri": "http://localhost:4400/",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read",
"audience": "https://scanner.local",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60
},
"apiBaseUrls": {
"authority": "https://authority.local",
"scanner": "https://scanner.local",
"policy": "https://scanner.local",
"concelier": "https://concelier.local",
"attestor": "https://attestor.local"
},
"telemetry": {
"otlpEndpoint": "http://localhost:4318/v1/traces",
"sampleRate": 0.1
}
}

View File

@@ -0,0 +1,26 @@
{
"authority": {
"issuer": "https://authority.example.dev",
"clientId": "stellaops-ui",
"authorizeEndpoint": "https://authority.example.dev/connect/authorize",
"tokenEndpoint": "https://authority.example.dev/connect/token",
"logoutEndpoint": "https://authority.example.dev/connect/logout",
"redirectUri": "http://localhost:4400/auth/callback",
"postLogoutRedirectUri": "http://localhost:4400/",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read",
"audience": "https://scanner.example.dev",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60
},
"apiBaseUrls": {
"authority": "https://authority.example.dev",
"scanner": "https://scanner.example.dev",
"policy": "https://scanner.example.dev",
"concelier": "https://concelier.example.dev",
"attestor": "https://attestor.example.dev"
},
"telemetry": {
"otlpEndpoint": "",
"sampleRate": 0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>StellaopsWeb</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
};
test.beforeEach(async ({ page }) => {
page.on('console', (message) => {
// bubble up browser logs for debugging
console.log('[browser]', message.type(), message.text());
});
page.on('pageerror', (error) => {
console.log('[pageerror]', error.message);
});
await page.addInitScript(() => {
// Capture attempted redirects so the test can assert against them.
(window as any).__stellaopsAssignedUrls = [];
const originalAssign = window.location.assign.bind(window.location);
window.location.assign = (url: string | URL) => {
(window as any).__stellaopsAssignedUrls.push(url.toString());
};
window.sessionStorage.clear();
});
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test('sign-in flow builds Authority authorization URL', async ({ page }) => {
await page.goto('/');
const signInButton = page.getByRole('button', { name: /sign in/i });
await expect(signInButton).toBeVisible();
const [request] = await Promise.all([
page.waitForRequest('https://authority.local/connect/authorize*'),
signInButton.click(),
]);
const authorizeUrl = new URL(request.url());
expect(authorizeUrl.origin).toBe('https://authority.local');
expect(authorizeUrl.pathname).toBe('/connect/authorize');
expect(authorizeUrl.searchParams.get('client_id')).toBe('stellaops-ui');
});
test('callback without pending state surfaces error message', async ({ page }) => {
await page.route('https://authority.local/**', (route) =>
route.fulfill({ status: 400, body: 'blocked' })
);
await page.goto('/auth/callback?code=test-code&state=missing');
await expect(
page.getByText('We were unable to complete the sign-in flow. Please try again.')
).toBeVisible({ timeout: 10000 });
});

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,33 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}