diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 04684a616..a66354360 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -79,6 +79,7 @@ { "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "https://exportcenter.stella-ops.local/api/v1/export$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" }, + { "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" }, { "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, diff --git a/docs/implplan/SPRINT_20260310_029_FE_notifications_surface_contract_and_frontdoor_split.md b/docs/implplan/SPRINT_20260310_029_FE_notifications_surface_contract_and_frontdoor_split.md new file mode 100644 index 000000000..ad844947f --- /dev/null +++ b/docs/implplan/SPRINT_20260310_029_FE_notifications_surface_contract_and_frontdoor_split.md @@ -0,0 +1,77 @@ +# Sprint 20260310-029 - Notifications Surface Contract And Frontdoor Split + +## Topic & Scope +- Restore the intended split between operator notifications and setup/admin notifications so `/ops/operations/notifications` stays an operator workflow while `/setup/notifications` hosts Notifications Studio. +- Repair the Notifications Studio web client so it talks to the documented Notifier frontdoor instead of stale legacy Notify endpoint shapes and paths. +- Add the missing router frontdoor mapping for the Notifier Studio API prefix and reverify both surfaces with focused tests and live Playwright. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular specs, focused router tests, live Playwright artifact, updated sprint log. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260310_028_FE_route_surface_ownership_alignment.md` for the route ownership baseline. +- Safe parallelism: avoid the unrelated dirty files already present under `src/Web/StellaOps.Web/src/app/features/approvals/`, `src/Web/StellaOps.Web/src/app/features/release-control/`, `src/Web/StellaOps.Web/src/app/features/security/`, `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/`, and `docs/implplan/SPRINT_20260310_026_Platform_global_context_propagation_header_cleanup.md`. +- Allowed coordination edits: `src/Router/StellaOps.Gateway.WebService/appsettings.json`, `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs`, `devops/compose/router-gateway-local.json`, `docs/modules/notify/architecture.md`, `docs/implplan/SPRINT_20260310_029_FE_notifications_surface_contract_and_frontdoor_split.md`. + +## Documentation Prerequisites +- `docs/modules/notify/architecture.md` +- `docs/features/checked/web/security-operations-leaves-ui.md` +- `docs/features/checked/web/notification-rule-simulation-escalation-policies.md` +- `docs/modules/router/webservices-valkey-rollout-matrix.md` + +## Delivery Tracker + +### NOTIFY-FRONTDOOR-029-001 - Restore route ownership for operator and admin notifications +Status: DONE +Dependency: none +Owners: QA, Developer +Task description: +- Move `/ops/operations/notifications` back onto the operator `NotifyPanelComponent` and mount Notifications Studio under `/setup/notifications` instead of redirecting setup traffic into ops. +- Update route contract specs so the ownership split is explicit and regressions are caught in tests. + +Completion criteria: +- [ ] `/ops/operations/notifications` renders the operator notifications shell rather than Notifications Studio. +- [ ] `/setup/notifications` is mounted directly and no longer redirects into ops. +- [ ] Route ownership specs cover both surfaces. + +### NOTIFY-FRONTDOOR-029-002 - Retarget Notifications Studio to the documented Notifier frontdoor +Status: DONE +Dependency: NOTIFY-FRONTDOOR-029-001 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the live Studio failures down to route ownership, stale API base URL, stale endpoint paths, and response-shape mismatches. +- Retarget the web client to the Notifier frontdoor prefix, normalize live collection envelopes, and use canonical Studio endpoint names instead of stale singular and misspelled paths. +- Add the missing router mapping for the Studio frontdoor prefix so the client reaches Notifier through the gateway without reintroducing broad reverse-proxy fallback. + +Completion criteria: +- [ ] The web client uses the canonical Notifier frontdoor prefix. +- [ ] Rules, channels, deliveries, quiet-hours, overrides, escalation policies, throttles, simulation, and preview calls use canonical endpoint names. +- [ ] Focused specs cover response normalization and frontdoor route presence. + +### NOTIFY-FRONTDOOR-029-003 - Reverify notifications surfaces live with Playwright +Status: DONE +Dependency: NOTIFY-FRONTDOOR-029-002 +Owners: QA +Task description: +- Rebuild the affected runtime slice, sync the new web bundle, and run live Playwright against both `/ops/operations/notifications` and `/setup/notifications`. +- Verify the operator watchlist handoff links render and land correctly, and verify the admin tabs load without runtime error banners or broken requests on the rebuilt stack. + +Completion criteria: +- [ ] Focused Angular/router tests pass. +- [ ] The rebuilt web bundle is synced into the live stack. +- [ ] Live Playwright verifies the operator and admin notifications surfaces without the previous `t.items is not iterable` failure. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after live Playwright showed `/ops/operations/notifications` was serving the wrong owner surface and `/setup/notifications` was coupled to stale Notifications Studio frontdoor contracts. | Developer | +| 2026-03-10 | Restored the operator/admin route split, added the router frontdoor mapping for `/api/v1/notifier/*`, corrected the accidental repointing of the operator `NotifyApi` to Notifier, rebuilt the web bundle, synced `compose_console-dist`, restarted `stellaops-router-gateway`, and reran the live Playwright notifications sweep cleanly. Focused Angular/Vitest and router tests passed before the live recheck. | Codex | + +## Decisions & Risks +- Decision: keep the product split documented in the UI dossiers: ops notifications remains the operator shell, while setup notifications remains the admin Studio. +- Decision: use the documented Notifier frontdoor prefix (`/api/v1/notifier`) and route it through explicit microservice mappings instead of broad reverse-proxy fallback. +- Decision: keep the legacy operator `NotifyApi` on `/api/v1/notify`; only the admin Notifications Studio moves to `/api/v1/notifier`. Mixing those two service contracts caused the live `newCollection[Symbol.iterator] is not a function` runtime failure on the operator page. +- Risk: the Notifier Studio backend currently emits mixed collection shapes across endpoints and tests; the web client must normalize both raw-array and envelope forms until the backend contracts are fully converged. + +## Next Checkpoints +- Land the route and frontdoor fixes with focused specs. +- Rebuild the router/web slice and rerun the live notifications Playwright sweep. diff --git a/docs/modules/notify/architecture.md b/docs/modules/notify/architecture.md index f485e3748..f71b58aec 100644 --- a/docs/modules/notify/architecture.md +++ b/docs/modules/notify/architecture.md @@ -1,5 +1,7 @@ > **Scope.** Implementation‑ready architecture for **Notify** (aligned with Epic 11 – Notifications Studio): a rules‑driven, tenant‑aware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operator‑defined routing rules, renders **channel‑specific messages** (Slack/Teams/Email/Webhook), and delivers them **reliably** with idempotency, throttling, and digests. It is UI‑managed, auditable, and safe by default (no secrets leakage, no spam storms). +* **Console frontdoor compatibility (updated 2026-03-10).** The web console reaches Notifier Studio through the gateway-owned `/api/v1/notifier/*` prefix, which translates onto the service-local `/api/v2/notify/*` surface without requiring browser calls to raw service-prefixed routes. + --- ## 0) Mission & boundaries diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 3372f4e44..216979d33 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -107,6 +107,7 @@ { "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" }, + { "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" }, { "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs index 2fd743f4e..0469acb26 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/GatewayRouteSearchMappingsTests.cs @@ -12,6 +12,7 @@ public sealed class GatewayRouteSearchMappingsTests ("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true), ("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true), ("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true), + ("^/api/v1/notifier/(.*)", "http://notifier.stella-ops.local/api/v2/notify/$1", "Microservice", true), ("^/api/v1/aoc(.*)", "http://platform.stella-ops.local/api/v1/aoc$1", "Microservice", true), ("^/api/v1/administration(.*)", "http://platform.stella-ops.local/api/v1/administration$1", "Microservice", true), ("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true), diff --git a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs index 2cfb9cb83..6432f1ed6 100644 --- a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs +++ b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs @@ -85,6 +85,45 @@ async function clickLinkAndVerify(page, route, linkName, expectedPath) { }; } +async function locateNav(page, label) { + const candidates = [ + page.getByRole('link', { name: label }).first(), + page.getByRole('tab', { name: label }).first(), + page.getByRole('button', { name: label }).first(), + ]; + + for (const locator of candidates) { + if ((await locator.count()) > 0) { + return locator; + } + } + + return null; +} + +async function clickNavAndVerify(page, route, label, expectedPath) { + await navigate(page, route); + const locator = await locateNav(page, label); + if (!locator) { + return { + action: `nav:${label}`, + ok: false, + reason: 'missing-nav', + snapshot: await captureSnapshot(page, `missing-nav:${label}`), + }; + } + + await locator.click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 }); + await settle(page); + + return { + action: `nav:${label}`, + ok: page.url().includes(expectedPath), + snapshot: await captureSnapshot(page, `after-nav:${label}`), + }; +} + async function main() { await mkdir(outputDir, { recursive: true }); @@ -146,6 +185,12 @@ async function main() { }); const results = []; + await navigate(page, '/ops/operations/notifications'); + results.push({ + action: 'route:/ops/operations/notifications', + ok: (await headingText(page)) === 'Notify control plane', + snapshot: await captureSnapshot(page, 'ops-notifications'), + }); results.push(await clickLinkAndVerify( page, '/ops/operations/notifications', @@ -158,6 +203,24 @@ async function main() { 'Review watchlist alerts', '/setup/trust-signing/watchlist/alerts', )); + await navigate(page, '/setup/notifications'); + const setupSnapshot = await captureSnapshot(page, 'setup-notifications'); + results.push({ + action: 'route:/setup/notifications', + ok: + setupSnapshot.heading === 'Notification Administration' && + !setupSnapshot.alerts.some((text) => text.toLowerCase().includes('items is not iterable')), + snapshot: setupSnapshot, + }); + results.push(await clickNavAndVerify(page, '/setup/notifications', 'Rules', '/setup/notifications/rules')); + results.push(await clickNavAndVerify(page, '/setup/notifications', 'Channels', '/setup/notifications/channels')); + results.push(await clickNavAndVerify(page, '/setup/notifications', 'Delivery', '/setup/notifications/delivery')); + results.push(await clickNavAndVerify(page, '/setup/notifications', 'Simulator', '/setup/notifications/simulator')); + results.push(await clickNavAndVerify(page, '/setup/notifications', 'Config', '/setup/notifications/config')); + results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Quiet Hours', '/setup/notifications/config/quiet-hours')); + results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Overrides', '/setup/notifications/config/overrides')); + results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Escalation', '/setup/notifications/config/escalation')); + results.push(await clickNavAndVerify(page, '/setup/notifications/config', 'Throttle', '/setup/notifications/config/throttle')); const summary = { generatedAtUtc: new Date().toISOString(), diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 20042c2da..87eb21bc9 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -1,416 +1,416 @@ -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; -import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router'; -import { PageTitleStrategy } from './core/navigation/page-title.strategy'; - -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 { - CONSOLE_API_BASE_URL, - DEFAULT_EVENT_SOURCE_FACTORY, - EVENT_SOURCE_FACTORY, -} from './core/api/console-status.client'; -import { - NOTIFY_API, - NOTIFY_API_BASE_URL, - NotifyApiHttpClient, - MockNotifyClient, -} from './core/api/notify.client'; -import { - EXCEPTION_API, - EXCEPTION_API_BASE_URL, - ExceptionApiHttpClient, - MockExceptionApiService, -} from './core/api/exception.client'; -import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; -import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client'; -import { RISK_API, MockRiskApi } from './core/api/risk.client'; -import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; -import { AppConfigService } from './core/config/app-config.service'; -import { I18nService } from './core/i18n'; -import { DoctorTrendService } from './core/doctor/doctor-trend.service'; -import { DoctorNotificationService } from './core/doctor/doctor-notification.service'; -import { BackendProbeService } from './core/config/backend-probe.service'; -import { OpenApiContextParamMap } from './core/context/openapi-context-param-map.service'; -import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; -import { AuthSessionStore } from './core/auth/auth-session.store'; -import { TenantActivationService } from './core/auth/tenant-activation.service'; -import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor'; -import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor'; -import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor'; -import { seedAuthSession, type StubAuthSession } from './testing'; -import { CVSS_API_BASE_URL } from './core/api/cvss.client'; -import { AUTH_SERVICE } from './core/auth'; -import { AuthorityAuthService } from './core/auth/authority-auth.service'; -import { AuthorityAuthAdapterService } from './core/auth/authority-auth-adapter.service'; -import { - ADVISORY_AI_API, - ADVISORY_AI_API_BASE_URL, - AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, -} from './core/api/advisory-ai.client'; -import { - ADVISORY_API, - ADVISORY_API_BASE_URL, - AdvisoryApiHttpClient, - MockAdvisoryApiService, -} from './core/api/advisories.client'; -import { - VEX_EVIDENCE_API, - VEX_EVIDENCE_API_BASE_URL, - VexEvidenceHttpClient, - MockVexEvidenceClient, -} from './core/api/vex-evidence.client'; -import { - VEX_DECISIONS_API, - VEX_DECISIONS_API_BASE_URL, - VexDecisionsHttpClient, - MockVexDecisionsClient, -} from './core/api/vex-decisions.client'; -import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; -import { - AUDIT_BUNDLES_API, - AUDIT_BUNDLES_API_BASE_URL, - AuditBundlesHttpClient, - MockAuditBundlesClient, -} from './core/api/audit-bundles.client'; -import { - POLICY_EXCEPTIONS_API, - POLICY_EXCEPTIONS_API_BASE_URL, - PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, -} from './core/api/policy-exceptions.client'; -import { - POLICY_EVIDENCE_API, - PolicyEvidenceCompositeClient, -} from './core/api/policy-evidence.client'; -import { - ORCHESTRATOR_API, - JOBENGINE_API_BASE_URL, - OrchestratorHttpClient, - MockJobEngineClient, -} from './core/api/jobengine.client'; -import { - ORCHESTRATOR_CONTROL_API, - JobEngineControlHttpClient, - MockJobEngineControlClient, -} from './core/api/jobengine-control.client'; -import { - FIRST_SIGNAL_API, - FirstSignalHttpClient, - MockFirstSignalClient, -} from './core/api/first-signal.client'; -import { - EXCEPTION_EVENTS_API, - EXCEPTION_EVENTS_API_BASE_URL, - ExceptionEventsHttpClient, - MockExceptionEventsApiService, -} from './core/api/exception-events.client'; -import { - EVIDENCE_PACK_API, - EVIDENCE_PACK_API_BASE_URL, - EvidencePackHttpClient, - MockEvidencePackClient, -} from './core/api/evidence-pack.client'; -import { - AI_RUNS_API, - AI_RUNS_API_BASE_URL, - AiRunsHttpClient, - MockAiRunsClient, -} from './core/api/ai-runs.client'; -import { - RELEASE_DASHBOARD_API, - RELEASE_DASHBOARD_API_BASE_URL, - ReleaseDashboardHttpClient, -} from './core/api/release-dashboard.client'; -import { - RELEASE_ENVIRONMENT_API, - RELEASE_ENVIRONMENT_API_BASE_URL, - ReleaseEnvironmentHttpClient, -} from './core/api/release-environment.client'; -import { - RELEASE_MANAGEMENT_API, - ReleaseManagementHttpClient, -} from './core/api/release-management.client'; -import { - WORKFLOW_API, - WorkflowHttpClient, -} from './core/api/workflow.client'; -import { - APPROVAL_API, - ApprovalHttpClient, -} from './core/api/approval.client'; -import { - DEPLOYMENT_API, - DeploymentHttpClient, -} from './core/api/deployment.client'; -import { - RELEASE_EVIDENCE_API, - ReleaseEvidenceHttpClient, -} from './core/api/release-evidence.client'; -import { - DOCTOR_API, - HttpDoctorClient, -} from './features/doctor/services/doctor.client'; -import { - WITNESS_API, - WitnessHttpClient, -} from './core/api/witness.client'; -import { - NOTIFIER_API, - NOTIFIER_API_BASE_URL, - NotifierApiHttpClient, - MockNotifierClient, -} from './core/api/notifier.client'; -import { - POLICY_ENGINE_API, - PolicyEngineHttpClient, - MockPolicyEngineApi, -} from './core/api/policy-engine.client'; -import { - TRUST_API, - TrustHttpService, - MockTrustApiService, -} from './core/api/trust.client'; -import { - VULN_ANNOTATION_API, - HttpVulnAnnotationClient, -} from './core/api/vuln-annotation.client'; -import { - AUTHORITY_ADMIN_API, - AUTHORITY_ADMIN_API_BASE_URL, - AuthorityAdminHttpClient, - MockAuthorityAdminClient, -} from './core/api/authority-admin.client'; -import { - SECURITY_FINDINGS_API, - SECURITY_FINDINGS_API_BASE_URL, - SecurityFindingsHttpClient, - MockSecurityFindingsClient, -} from './core/api/security-findings.client'; -import { - SECURITY_OVERVIEW_API, - SecurityOverviewHttpClient, - MockSecurityOverviewClient, -} from './core/api/security-overview.client'; -import { - CONSOLE_VULN_API, - ConsoleVulnHttpClient, - MockConsoleVulnClient, -} from './core/api/console-vuln.client'; -import { - REACHABILITY_API, - ReachabilityClient, - MockReachabilityApi, -} from './core/api/reachability.client'; -import { - SCHEDULER_API, - SCHEDULER_API_BASE_URL, - SchedulerHttpClient, - MockSchedulerClient, -} from './core/api/scheduler.client'; -import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; -import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; -import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; -import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; -import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client'; -import { - POLICY_SIMULATION_API, - POLICY_SIMULATION_API_BASE_URL, - PolicySimulationHttpClient, -} from './core/api/policy-simulation.client'; -import { - GRAPH_API_BASE_URL, - GRAPH_PLATFORM_API, - GraphPlatformHttpClient, -} from './core/api/graph-platform.client'; -import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client'; -import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client'; -import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client'; -import { VERDICT_API, HttpVerdictClient } from './core/api/verdict.client'; -import { WATCHLIST_API, WatchlistHttpClient } from './core/api/watchlist.client'; -import { EVIDENCE_API, EVIDENCE_API_BASE_URL, EvidenceHttpClient } from './core/api/evidence.client'; -import { - MANIFEST_API, - PROOF_BUNDLE_API, - SCORE_REPLAY_API, - ManifestClient, - ProofBundleClient, - ScoreReplayClient, -} from './core/api/proof.client'; -import { SBOM_EVIDENCE_API, SbomEvidenceService } from './features/sbom/services/sbom-evidence.service'; -import { HttpReplayClient } from './core/api/replay.client'; -import { REPLAY_API } from './features/proofs/proof-replay-dashboard.component'; -import { HttpScoreClient } from './core/api/score.client'; -import { SCORE_API } from './features/scores/score-comparison.component'; -import { AOC_API, AOC_API_BASE_URL, AOC_SOURCES_API_BASE_URL, AocHttpClient } from './core/api/aoc.client'; -import { DELTA_VERDICT_API, HttpDeltaVerdictApi } from './core/services/delta-verdict.service'; -import { RISK_BUDGET_API, HttpRiskBudgetApi } from './core/services/risk-budget.service'; -import { FIX_VERIFICATION_API, FixVerificationApiClient } from './core/services/fix-verification.service'; -import { SCORING_API, HttpScoringApi } from './core/services/scoring.service'; -import { ABAC_OVERLAY_API, AbacOverlayHttpClient } from './core/api/abac-overlay.client'; -import { - IDENTITY_PROVIDER_API, - IDENTITY_PROVIDER_API_BASE_URL, - IdentityProviderApiHttpClient, - MockIdentityProviderClient, -} from './core/api/identity-provider.client'; - -function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { - const normalizedBase = (baseUrl ?? '').trim(); - - if (!normalizedBase) { - return path; - } - - try { - return new URL(path, normalizedBase).toString(); - } catch { - if (path.startsWith('/')) { - return path; - } - - const baseWithoutTrailingSlash = normalizedBase.endsWith('/') - ? normalizedBase.slice(0, -1) - : normalizedBase; - - return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`; - } -} - -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(routes, withComponentInputBinding()), - provideAnimationsAsync(), - { provide: TitleStrategy, useClass: PageTitleStrategy }, - provideHttpClient(withInterceptorsFromDi()), - provideAppInitializer(() => { - const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => { - await configService.load(); - await i18nService.loadTranslations(); - await openApiParamMap.initialize(); - if (configService.isConfigured()) { - probeService.probe(); - } +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router'; +import { PageTitleStrategy } from './core/navigation/page-title.strategy'; + +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 { + CONSOLE_API_BASE_URL, + DEFAULT_EVENT_SOURCE_FACTORY, + EVENT_SOURCE_FACTORY, +} from './core/api/console-status.client'; +import { + NOTIFY_API, + NOTIFY_API_BASE_URL, + NotifyApiHttpClient, + MockNotifyClient, +} from './core/api/notify.client'; +import { + EXCEPTION_API, + EXCEPTION_API_BASE_URL, + ExceptionApiHttpClient, + MockExceptionApiService, +} from './core/api/exception.client'; +import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; +import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client'; +import { RISK_API, MockRiskApi } from './core/api/risk.client'; +import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; +import { AppConfigService } from './core/config/app-config.service'; +import { I18nService } from './core/i18n'; +import { DoctorTrendService } from './core/doctor/doctor-trend.service'; +import { DoctorNotificationService } from './core/doctor/doctor-notification.service'; +import { BackendProbeService } from './core/config/backend-probe.service'; +import { OpenApiContextParamMap } from './core/context/openapi-context-param-map.service'; +import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; +import { AuthSessionStore } from './core/auth/auth-session.store'; +import { TenantActivationService } from './core/auth/tenant-activation.service'; +import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor'; +import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor'; +import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor'; +import { seedAuthSession, type StubAuthSession } from './testing'; +import { CVSS_API_BASE_URL } from './core/api/cvss.client'; +import { AUTH_SERVICE } from './core/auth'; +import { AuthorityAuthService } from './core/auth/authority-auth.service'; +import { AuthorityAuthAdapterService } from './core/auth/authority-auth-adapter.service'; +import { + ADVISORY_AI_API, + ADVISORY_AI_API_BASE_URL, + AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, +} from './core/api/advisory-ai.client'; +import { + ADVISORY_API, + ADVISORY_API_BASE_URL, + AdvisoryApiHttpClient, + MockAdvisoryApiService, +} from './core/api/advisories.client'; +import { + VEX_EVIDENCE_API, + VEX_EVIDENCE_API_BASE_URL, + VexEvidenceHttpClient, + MockVexEvidenceClient, +} from './core/api/vex-evidence.client'; +import { + VEX_DECISIONS_API, + VEX_DECISIONS_API_BASE_URL, + VexDecisionsHttpClient, + MockVexDecisionsClient, +} from './core/api/vex-decisions.client'; +import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; +import { + AUDIT_BUNDLES_API, + AUDIT_BUNDLES_API_BASE_URL, + AuditBundlesHttpClient, + MockAuditBundlesClient, +} from './core/api/audit-bundles.client'; +import { + POLICY_EXCEPTIONS_API, + POLICY_EXCEPTIONS_API_BASE_URL, + PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, +} from './core/api/policy-exceptions.client'; +import { + POLICY_EVIDENCE_API, + PolicyEvidenceCompositeClient, +} from './core/api/policy-evidence.client'; +import { + ORCHESTRATOR_API, + JOBENGINE_API_BASE_URL, + OrchestratorHttpClient, + MockJobEngineClient, +} from './core/api/jobengine.client'; +import { + ORCHESTRATOR_CONTROL_API, + JobEngineControlHttpClient, + MockJobEngineControlClient, +} from './core/api/jobengine-control.client'; +import { + FIRST_SIGNAL_API, + FirstSignalHttpClient, + MockFirstSignalClient, +} from './core/api/first-signal.client'; +import { + EXCEPTION_EVENTS_API, + EXCEPTION_EVENTS_API_BASE_URL, + ExceptionEventsHttpClient, + MockExceptionEventsApiService, +} from './core/api/exception-events.client'; +import { + EVIDENCE_PACK_API, + EVIDENCE_PACK_API_BASE_URL, + EvidencePackHttpClient, + MockEvidencePackClient, +} from './core/api/evidence-pack.client'; +import { + AI_RUNS_API, + AI_RUNS_API_BASE_URL, + AiRunsHttpClient, + MockAiRunsClient, +} from './core/api/ai-runs.client'; +import { + RELEASE_DASHBOARD_API, + RELEASE_DASHBOARD_API_BASE_URL, + ReleaseDashboardHttpClient, +} from './core/api/release-dashboard.client'; +import { + RELEASE_ENVIRONMENT_API, + RELEASE_ENVIRONMENT_API_BASE_URL, + ReleaseEnvironmentHttpClient, +} from './core/api/release-environment.client'; +import { + RELEASE_MANAGEMENT_API, + ReleaseManagementHttpClient, +} from './core/api/release-management.client'; +import { + WORKFLOW_API, + WorkflowHttpClient, +} from './core/api/workflow.client'; +import { + APPROVAL_API, + ApprovalHttpClient, +} from './core/api/approval.client'; +import { + DEPLOYMENT_API, + DeploymentHttpClient, +} from './core/api/deployment.client'; +import { + RELEASE_EVIDENCE_API, + ReleaseEvidenceHttpClient, +} from './core/api/release-evidence.client'; +import { + DOCTOR_API, + HttpDoctorClient, +} from './features/doctor/services/doctor.client'; +import { + WITNESS_API, + WitnessHttpClient, +} from './core/api/witness.client'; +import { + NOTIFIER_API, + NOTIFIER_API_BASE_URL, + NotifierApiHttpClient, + MockNotifierClient, +} from './core/api/notifier.client'; +import { + POLICY_ENGINE_API, + PolicyEngineHttpClient, + MockPolicyEngineApi, +} from './core/api/policy-engine.client'; +import { + TRUST_API, + TrustHttpService, + MockTrustApiService, +} from './core/api/trust.client'; +import { + VULN_ANNOTATION_API, + HttpVulnAnnotationClient, +} from './core/api/vuln-annotation.client'; +import { + AUTHORITY_ADMIN_API, + AUTHORITY_ADMIN_API_BASE_URL, + AuthorityAdminHttpClient, + MockAuthorityAdminClient, +} from './core/api/authority-admin.client'; +import { + SECURITY_FINDINGS_API, + SECURITY_FINDINGS_API_BASE_URL, + SecurityFindingsHttpClient, + MockSecurityFindingsClient, +} from './core/api/security-findings.client'; +import { + SECURITY_OVERVIEW_API, + SecurityOverviewHttpClient, + MockSecurityOverviewClient, +} from './core/api/security-overview.client'; +import { + CONSOLE_VULN_API, + ConsoleVulnHttpClient, + MockConsoleVulnClient, +} from './core/api/console-vuln.client'; +import { + REACHABILITY_API, + ReachabilityClient, + MockReachabilityApi, +} from './core/api/reachability.client'; +import { + SCHEDULER_API, + SCHEDULER_API_BASE_URL, + SchedulerHttpClient, + MockSchedulerClient, +} from './core/api/scheduler.client'; +import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; +import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; +import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; +import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; +import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client'; +import { + POLICY_SIMULATION_API, + POLICY_SIMULATION_API_BASE_URL, + PolicySimulationHttpClient, +} from './core/api/policy-simulation.client'; +import { + GRAPH_API_BASE_URL, + GRAPH_PLATFORM_API, + GraphPlatformHttpClient, +} from './core/api/graph-platform.client'; +import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client'; +import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client'; +import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client'; +import { VERDICT_API, HttpVerdictClient } from './core/api/verdict.client'; +import { WATCHLIST_API, WatchlistHttpClient } from './core/api/watchlist.client'; +import { EVIDENCE_API, EVIDENCE_API_BASE_URL, EvidenceHttpClient } from './core/api/evidence.client'; +import { + MANIFEST_API, + PROOF_BUNDLE_API, + SCORE_REPLAY_API, + ManifestClient, + ProofBundleClient, + ScoreReplayClient, +} from './core/api/proof.client'; +import { SBOM_EVIDENCE_API, SbomEvidenceService } from './features/sbom/services/sbom-evidence.service'; +import { HttpReplayClient } from './core/api/replay.client'; +import { REPLAY_API } from './features/proofs/proof-replay-dashboard.component'; +import { HttpScoreClient } from './core/api/score.client'; +import { SCORE_API } from './features/scores/score-comparison.component'; +import { AOC_API, AOC_API_BASE_URL, AOC_SOURCES_API_BASE_URL, AocHttpClient } from './core/api/aoc.client'; +import { DELTA_VERDICT_API, HttpDeltaVerdictApi } from './core/services/delta-verdict.service'; +import { RISK_BUDGET_API, HttpRiskBudgetApi } from './core/services/risk-budget.service'; +import { FIX_VERIFICATION_API, FixVerificationApiClient } from './core/services/fix-verification.service'; +import { SCORING_API, HttpScoringApi } from './core/services/scoring.service'; +import { ABAC_OVERLAY_API, AbacOverlayHttpClient } from './core/api/abac-overlay.client'; +import { + IDENTITY_PROVIDER_API, + IDENTITY_PROVIDER_API_BASE_URL, + IdentityProviderApiHttpClient, + MockIdentityProviderClient, +} from './core/api/identity-provider.client'; + +function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { + const normalizedBase = (baseUrl ?? '').trim(); + + if (!normalizedBase) { + return path; + } + + try { + return new URL(path, normalizedBase).toString(); + } catch { + if (path.startsWith('/')) { + return path; + } + + const baseWithoutTrailingSlash = normalizedBase.endsWith('/') + ? normalizedBase.slice(0, -1) + : normalizedBase; + + return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`; + } +} + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding()), + provideAnimationsAsync(), + { provide: TitleStrategy, useClass: PageTitleStrategy }, + provideHttpClient(withInterceptorsFromDi()), + provideAppInitializer(() => { + const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => { + await configService.load(); + await i18nService.loadTranslations(); + await openApiParamMap.initialize(); + if (configService.isConfigured()) { + probeService.probe(); + } })(inject(AppConfigService), inject(BackendProbeService), inject(I18nService), inject(OpenApiContextParamMap)); - return initializerFn(); - }), - { - provide: HTTP_INTERCEPTORS, - useClass: AuthHttpInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: OperatorMetadataInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: TenantHttpInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: GlobalContextHttpInterceptor, - 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 gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/console'); - }, - }, - AuthorityConsoleApiHttpClient, - { - provide: AUTHORITY_CONSOLE_API, - useExisting: AuthorityConsoleApiHttpClient, - }, - provideAppInitializer(() => { - const initializerFn = ((store: AuthSessionStore, tenantActivation: TenantActivationService) => () => { - if (typeof window === 'undefined') return; - const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined; - if (!stub) return; - try { - seedAuthSession(store, stub); - tenantActivation.activateTenant(stub.tenant); - } catch (err) { - console.warn('Failed to seed test session', err); - } - })(inject(AuthSessionStore), inject(TenantActivationService)); - return initializerFn(); - }), - { - provide: RISK_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const authorityBase = config.config.apiBaseUrls.authority; - try { - return new URL('/risk', authorityBase).toString(); - } catch { - const normalized = authorityBase.endsWith('/') - ? authorityBase.slice(0, -1) - : authorityBase; - return `${normalized}/risk`; - } - }, - }, - { - provide: AUTH_SERVICE, - useExisting: AuthorityAuthAdapterService, - }, - { - provide: CVSS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const policyBase = config.config.apiBaseUrls.policy; - try { - return new URL('/api/cvss', policyBase).toString(); - } catch { - const normalized = policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; - return `${normalized}/api/cvss`; - } - }, - }, - RiskHttpClient, - MockRiskApi, - { - provide: RISK_API, - useExisting: RiskHttpClient, - }, - { - provide: VULNERABILITY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const authorityBase = config.config.apiBaseUrls.authority; - try { - return new URL('/vuln', authorityBase).toString(); - } catch { - const normalized = authorityBase.endsWith('/') - ? authorityBase.slice(0, -1) - : authorityBase; - return `${normalized}/vuln`; - } - }, - }, - VulnerabilityHttpClient, - MockVulnerabilityApiService, - { - provide: VULNERABILITY_API, - useExisting: VulnerabilityHttpClient, - }, + return initializerFn(); + }), + { + provide: HTTP_INTERCEPTORS, + useClass: AuthHttpInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: OperatorMetadataInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: TenantHttpInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: GlobalContextHttpInterceptor, + 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 gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/console'); + }, + }, + AuthorityConsoleApiHttpClient, + { + provide: AUTHORITY_CONSOLE_API, + useExisting: AuthorityConsoleApiHttpClient, + }, + provideAppInitializer(() => { + const initializerFn = ((store: AuthSessionStore, tenantActivation: TenantActivationService) => () => { + if (typeof window === 'undefined') return; + const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined; + if (!stub) return; + try { + seedAuthSession(store, stub); + tenantActivation.activateTenant(stub.tenant); + } catch (err) { + console.warn('Failed to seed test session', err); + } + })(inject(AuthSessionStore), inject(TenantActivationService)); + return initializerFn(); + }), + { + provide: RISK_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const authorityBase = config.config.apiBaseUrls.authority; + try { + return new URL('/risk', authorityBase).toString(); + } catch { + const normalized = authorityBase.endsWith('/') + ? authorityBase.slice(0, -1) + : authorityBase; + return `${normalized}/risk`; + } + }, + }, + { + provide: AUTH_SERVICE, + useExisting: AuthorityAuthAdapterService, + }, + { + provide: CVSS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const policyBase = config.config.apiBaseUrls.policy; + try { + return new URL('/api/cvss', policyBase).toString(); + } catch { + const normalized = policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; + return `${normalized}/api/cvss`; + } + }, + }, + RiskHttpClient, + MockRiskApi, + { + provide: RISK_API, + useExisting: RiskHttpClient, + }, + { + provide: VULNERABILITY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const authorityBase = config.config.apiBaseUrls.authority; + try { + return new URL('/vuln', authorityBase).toString(); + } catch { + const normalized = authorityBase.endsWith('/') + ? authorityBase.slice(0, -1) + : authorityBase; + return `${normalized}/vuln`; + } + }, + }, + VulnerabilityHttpClient, + MockVulnerabilityApiService, + { + provide: VULNERABILITY_API, + useExisting: VulnerabilityHttpClient, + }, { provide: NOTIFY_API_BASE_URL, deps: [AppConfigService], @@ -424,679 +424,679 @@ export const appConfig: ApplicationConfig = { } }, }, - { - provide: ADVISORY_AI_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/v1/advisory-ai', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/v1/advisory-ai`; - } - }, - }, - AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, - { - provide: ADVISORY_AI_API, - useExisting: AdvisoryAiApiHttpClient, - }, - { - provide: ADVISORY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - AdvisoryApiHttpClient, - MockAdvisoryApiService, - { - provide: ADVISORY_API, - useExisting: AdvisoryApiHttpClient, - }, - { - provide: VEX_EVIDENCE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - { - provide: VEX_HUB_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/vex', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/vex`; - } - }, - }, - { - provide: VEX_LENS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/vexlens', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/vexlens`; - } - }, - }, - VexHubApiHttpClient, - MockVexHubClient, - { - provide: VEX_HUB_API, - useExisting: VexHubApiHttpClient, - }, - VexEvidenceHttpClient, - MockVexEvidenceClient, - { - provide: VEX_EVIDENCE_API, - useExisting: VexEvidenceHttpClient, - }, - { - provide: VEX_DECISIONS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - VexDecisionsHttpClient, - MockVexDecisionsClient, - { - provide: VEX_DECISIONS_API, - useExisting: VexDecisionsHttpClient, - }, - { - provide: AUDIT_BUNDLES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - AuditBundlesHttpClient, - MockAuditBundlesClient, - { - provide: AUDIT_BUNDLES_API, - useExisting: AuditBundlesHttpClient, - }, - { - provide: POLICY_EXCEPTIONS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, - { - provide: POLICY_EXCEPTIONS_API, - useExisting: PolicyExceptionsHttpClient, - }, - PolicyEvidenceCompositeClient, - { - provide: POLICY_EVIDENCE_API, - useExisting: PolicyEvidenceCompositeClient, - }, - { - provide: JOBENGINE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/v1'); - }, - }, - OrchestratorHttpClient, - MockJobEngineClient, - { - provide: ORCHESTRATOR_API, - useExisting: OrchestratorHttpClient, - }, - JobEngineControlHttpClient, - MockJobEngineControlClient, - { - provide: ORCHESTRATOR_CONTROL_API, - useExisting: JobEngineControlHttpClient, - }, - FirstSignalHttpClient, - MockFirstSignalClient, - { - provide: FIRST_SIGNAL_API, - useExisting: FirstSignalHttpClient, - }, - { - provide: EXCEPTION_EVENTS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - ExceptionEventsHttpClient, - MockExceptionEventsApiService, - { - provide: EXCEPTION_EVENTS_API, - useExisting: ExceptionEventsHttpClient, - }, - { - provide: EXCEPTION_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/policy', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/policy`; - } - }, - }, - ExceptionApiHttpClient, - MockExceptionApiService, - { - provide: EXCEPTION_API, - useExisting: ExceptionApiHttpClient, - }, - { - provide: EVIDENCE_PACK_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/v1/evidence-packs', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/v1/evidence-packs`; - } - }, - }, - EvidencePackHttpClient, - MockEvidencePackClient, - { - provide: EVIDENCE_PACK_API, - useExisting: EvidencePackHttpClient, - }, - { - provide: AI_RUNS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs'); - }, - }, - AiRunsHttpClient, - MockAiRunsClient, - { - provide: AI_RUNS_API, - useExisting: AiRunsHttpClient, - }, - { - provide: CONSOLE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/console'); - }, - }, - { - provide: EVENT_SOURCE_FACTORY, - useValue: DEFAULT_EVENT_SOURCE_FACTORY, - }, - NotifyApiHttpClient, - MockNotifyClient, - { - provide: NOTIFY_API, - useExisting: NotifyApiHttpClient, - }, - // Release Dashboard API (runtime HTTP client) - { - provide: RELEASE_DASHBOARD_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/release-jobengine', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/release-orchestrator`; - } - }, - }, - ReleaseDashboardHttpClient, - { - provide: RELEASE_DASHBOARD_API, - useExisting: ReleaseDashboardHttpClient, - }, - // Release Environment API (Sprint 111_002) - { - provide: RELEASE_ENVIRONMENT_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/release-jobengine', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/release-orchestrator`; - } - }, - }, - ReleaseEnvironmentHttpClient, - { - provide: RELEASE_ENVIRONMENT_API, - useExisting: ReleaseEnvironmentHttpClient, - }, - // Release Management API (runtime HTTP client) - ReleaseManagementHttpClient, - { - provide: RELEASE_MANAGEMENT_API, - useExisting: ReleaseManagementHttpClient, - }, - // Workflow API (runtime HTTP client) - WorkflowHttpClient, - { - provide: WORKFLOW_API, - useExisting: WorkflowHttpClient, - }, - // Approval API (runtime HTTP client) - ApprovalHttpClient, - { - provide: APPROVAL_API, - useExisting: ApprovalHttpClient, - }, - // Deployment API (runtime HTTP client) - DeploymentHttpClient, - { - provide: DEPLOYMENT_API, - useExisting: DeploymentHttpClient, - }, - // Release Evidence API (runtime HTTP client) - ReleaseEvidenceHttpClient, - { - provide: RELEASE_EVIDENCE_API, - useExisting: ReleaseEvidenceHttpClient, - }, - // Doctor API (runtime HTTP client) - HttpDoctorClient, - { - provide: DOCTOR_API, - useExisting: HttpDoctorClient, - }, - // Witness API (Sprint 20260112_013_FE_witness_ui_wiring) - WitnessHttpClient, - { - provide: WITNESS_API, - useExisting: WitnessHttpClient, - }, - // Notifier API (Bug fix: missing DI providers caused NG0201) - { - provide: NOTIFIER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/notify', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/notify`; - } - }, - }, - NotifierApiHttpClient, - MockNotifierClient, - { - provide: NOTIFIER_API, - useExisting: NotifierApiHttpClient, - }, - // Policy Engine API - PolicyEngineHttpClient, - MockPolicyEngineApi, - { - provide: POLICY_ENGINE_API, - useExisting: PolicyEngineHttpClient, - }, - // Trust API - TrustHttpService, - MockTrustApiService, - { - provide: TRUST_API, - useExisting: TrustHttpService, - }, - // ABAC overlay API - AbacOverlayHttpClient, - { - provide: ABAC_OVERLAY_API, - useExisting: AbacOverlayHttpClient, - }, - // Vuln Annotation API (runtime HTTP client) - HttpVulnAnnotationClient, - { - provide: VULN_ANNOTATION_API, - useExisting: HttpVulnAnnotationClient, - }, - // Authority Admin API (admin CRUD for users/roles/clients/tokens/tenants) - { - provide: AUTHORITY_ADMIN_API_BASE_URL, - useValue: '/console/admin', - }, - AuthorityAdminHttpClient, - MockAuthorityAdminClient, - { - provide: AUTHORITY_ADMIN_API, - useExisting: AuthorityAdminHttpClient, - }, - // Security Findings API (findings ledger via gateway) - { - provide: SECURITY_FINDINGS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - SecurityFindingsHttpClient, - MockSecurityFindingsClient, - { - provide: SECURITY_FINDINGS_API, - useExisting: SecurityFindingsHttpClient, - }, - // Security Overview API (aggregated security dashboard data) - SecurityOverviewHttpClient, - MockSecurityOverviewClient, - { - provide: SECURITY_OVERVIEW_API, - useExisting: SecurityOverviewHttpClient, - }, - // Scheduler API (schedule CRUD) - { - provide: SCHEDULER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/scheduler/api/v1/scheduler`; - } - }, - }, - SchedulerHttpClient, - MockSchedulerClient, - { - provide: SCHEDULER_API, - useExisting: SchedulerHttpClient, - }, - // Console Vuln API - ConsoleVulnHttpClient, - MockConsoleVulnClient, - { - provide: CONSOLE_VULN_API, - useExisting: ConsoleVulnHttpClient, - }, - // Reachability API - ReachabilityClient, - MockReachabilityApi, - { - provide: REACHABILITY_API, - useExisting: ReachabilityClient, - }, - // Analytics API - AnalyticsHttpClient, - // Feed Mirror API (Concelier backend via gateway) - { - provide: FEED_MIRROR_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/concelier', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/concelier`; - } - }, - }, - FeedMirrorHttpClient, - { - provide: FEED_MIRROR_API, - useExisting: FeedMirrorHttpClient, - }, - // Attestation Chain API - AttestationChainHttpClient, - { - provide: ATTESTATION_CHAIN_API, - useExisting: AttestationChainHttpClient, - }, - // Console Search API - ConsoleSearchHttpClient, - { - provide: CONSOLE_SEARCH_API, - useExisting: ConsoleSearchHttpClient, - }, - // Policy Governance API - HttpPolicyGovernanceApi, - { - provide: POLICY_GOVERNANCE_API, - useExisting: HttpPolicyGovernanceApi, - }, - // Policy Simulation API - { - provide: POLICY_SIMULATION_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const policyBase = config.config.apiBaseUrls.policy; - return policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; - }, - }, - PolicySimulationHttpClient, - { - provide: POLICY_SIMULATION_API, - useExisting: PolicySimulationHttpClient, - }, - // Graph Platform API - { - provide: GRAPH_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/graph', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/graph`; - } - }, - }, - GraphPlatformHttpClient, - { - provide: GRAPH_PLATFORM_API, - useExisting: GraphPlatformHttpClient, - }, - // Policy Gates API (Policy Gateway backend) - { - provide: POLICY_GATES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/policy', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/policy`; - } - }, - }, - PolicyGatesHttpClient, - { - provide: POLICY_GATES_API, - useExisting: PolicyGatesHttpClient, - }, - // Release API (Release JobEngine backend) - ReleaseHttpClient, - { - provide: RELEASE_API, - useExisting: ReleaseHttpClient, - }, - // Triage Evidence API - TriageEvidenceHttpClient, - { - provide: TRIAGE_EVIDENCE_API, - useExisting: TriageEvidenceHttpClient, - }, - // Verdict API - HttpVerdictClient, - { - provide: VERDICT_API, - useExisting: HttpVerdictClient, - }, - // Watchlist API - WatchlistHttpClient, - { - provide: WATCHLIST_API, - useExisting: WatchlistHttpClient, - }, - // Evidence API (Evidence Locker backend via gateway) - { - provide: EVIDENCE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/evidence', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/evidence`; - } - }, - }, - EvidenceHttpClient, - { - provide: EVIDENCE_API, - useExisting: EvidenceHttpClient, - }, - // Proof APIs (Manifest, Bundle, Score Replay) - ManifestClient, - ProofBundleClient, - ScoreReplayClient, - { - provide: MANIFEST_API, - useExisting: ManifestClient, - }, - { - provide: PROOF_BUNDLE_API, - useExisting: ProofBundleClient, - }, - { - provide: SCORE_REPLAY_API, - useExisting: ScoreReplayClient, - }, - // SBOM Evidence API - SbomEvidenceService, - { - provide: SBOM_EVIDENCE_API, - useExisting: SbomEvidenceService, - }, - // Replay API - HttpReplayClient, - { - provide: REPLAY_API, - useExisting: HttpReplayClient, - }, - // Score API - HttpScoreClient, - { - provide: SCORE_API, - useExisting: HttpScoreClient, - }, - // Evidence-weighted scoring API - HttpScoringApi, - { - provide: SCORING_API, - useExisting: HttpScoringApi, - }, - // Risk dashboard and fix verification stores - HttpDeltaVerdictApi, - { - provide: DELTA_VERDICT_API, - useExisting: HttpDeltaVerdictApi, - }, - HttpRiskBudgetApi, - { - provide: RISK_BUDGET_API, - useExisting: HttpRiskBudgetApi, - }, - FixVerificationApiClient, - { - provide: FIX_VERIFICATION_API, - useExisting: FixVerificationApiClient, - }, - // AOC API (Attestor + Sources backend via gateway) - { - provide: AOC_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/attestor', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/attestor`; - } - }, - }, - { - provide: AOC_SOURCES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/sources', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/sources`; - } - }, - }, - AocHttpClient, - { provide: AOC_API, useExisting: AocHttpClient }, - - // Identity Provider API (Platform backend via gateway) - { - provide: IDENTITY_PROVIDER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/platform/identity-providers', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/platform/identity-providers`; - } - }, - }, - IdentityProviderApiHttpClient, - MockIdentityProviderClient, - { - provide: IDENTITY_PROVIDER_API, - useExisting: IdentityProviderApiHttpClient, - }, - - // Doctor background services — started from AppComponent to avoid - // NG0200 circular DI during APP_INITIALIZER (Router not yet ready). - DoctorTrendService, - DoctorNotificationService, - ], -}; + { + provide: ADVISORY_AI_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/v1/advisory-ai', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/v1/advisory-ai`; + } + }, + }, + AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, + { + provide: ADVISORY_AI_API, + useExisting: AdvisoryAiApiHttpClient, + }, + { + provide: ADVISORY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + AdvisoryApiHttpClient, + MockAdvisoryApiService, + { + provide: ADVISORY_API, + useExisting: AdvisoryApiHttpClient, + }, + { + provide: VEX_EVIDENCE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + { + provide: VEX_HUB_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/vex', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/vex`; + } + }, + }, + { + provide: VEX_LENS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/vexlens', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/vexlens`; + } + }, + }, + VexHubApiHttpClient, + MockVexHubClient, + { + provide: VEX_HUB_API, + useExisting: VexHubApiHttpClient, + }, + VexEvidenceHttpClient, + MockVexEvidenceClient, + { + provide: VEX_EVIDENCE_API, + useExisting: VexEvidenceHttpClient, + }, + { + provide: VEX_DECISIONS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + VexDecisionsHttpClient, + MockVexDecisionsClient, + { + provide: VEX_DECISIONS_API, + useExisting: VexDecisionsHttpClient, + }, + { + provide: AUDIT_BUNDLES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + AuditBundlesHttpClient, + MockAuditBundlesClient, + { + provide: AUDIT_BUNDLES_API, + useExisting: AuditBundlesHttpClient, + }, + { + provide: POLICY_EXCEPTIONS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, + { + provide: POLICY_EXCEPTIONS_API, + useExisting: PolicyExceptionsHttpClient, + }, + PolicyEvidenceCompositeClient, + { + provide: POLICY_EVIDENCE_API, + useExisting: PolicyEvidenceCompositeClient, + }, + { + provide: JOBENGINE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1'); + }, + }, + OrchestratorHttpClient, + MockJobEngineClient, + { + provide: ORCHESTRATOR_API, + useExisting: OrchestratorHttpClient, + }, + JobEngineControlHttpClient, + MockJobEngineControlClient, + { + provide: ORCHESTRATOR_CONTROL_API, + useExisting: JobEngineControlHttpClient, + }, + FirstSignalHttpClient, + MockFirstSignalClient, + { + provide: FIRST_SIGNAL_API, + useExisting: FirstSignalHttpClient, + }, + { + provide: EXCEPTION_EVENTS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + ExceptionEventsHttpClient, + MockExceptionEventsApiService, + { + provide: EXCEPTION_EVENTS_API, + useExisting: ExceptionEventsHttpClient, + }, + { + provide: EXCEPTION_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } + }, + }, + ExceptionApiHttpClient, + MockExceptionApiService, + { + provide: EXCEPTION_API, + useExisting: ExceptionApiHttpClient, + }, + { + provide: EVIDENCE_PACK_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/v1/evidence-packs', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/v1/evidence-packs`; + } + }, + }, + EvidencePackHttpClient, + MockEvidencePackClient, + { + provide: EVIDENCE_PACK_API, + useExisting: EvidencePackHttpClient, + }, + { + provide: AI_RUNS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs'); + }, + }, + AiRunsHttpClient, + MockAiRunsClient, + { + provide: AI_RUNS_API, + useExisting: AiRunsHttpClient, + }, + { + provide: CONSOLE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/console'); + }, + }, + { + provide: EVENT_SOURCE_FACTORY, + useValue: DEFAULT_EVENT_SOURCE_FACTORY, + }, + NotifyApiHttpClient, + MockNotifyClient, + { + provide: NOTIFY_API, + useExisting: NotifyApiHttpClient, + }, + // Release Dashboard API (runtime HTTP client) + { + provide: RELEASE_DASHBOARD_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/release-jobengine', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/release-orchestrator`; + } + }, + }, + ReleaseDashboardHttpClient, + { + provide: RELEASE_DASHBOARD_API, + useExisting: ReleaseDashboardHttpClient, + }, + // Release Environment API (Sprint 111_002) + { + provide: RELEASE_ENVIRONMENT_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/release-jobengine', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/release-orchestrator`; + } + }, + }, + ReleaseEnvironmentHttpClient, + { + provide: RELEASE_ENVIRONMENT_API, + useExisting: ReleaseEnvironmentHttpClient, + }, + // Release Management API (runtime HTTP client) + ReleaseManagementHttpClient, + { + provide: RELEASE_MANAGEMENT_API, + useExisting: ReleaseManagementHttpClient, + }, + // Workflow API (runtime HTTP client) + WorkflowHttpClient, + { + provide: WORKFLOW_API, + useExisting: WorkflowHttpClient, + }, + // Approval API (runtime HTTP client) + ApprovalHttpClient, + { + provide: APPROVAL_API, + useExisting: ApprovalHttpClient, + }, + // Deployment API (runtime HTTP client) + DeploymentHttpClient, + { + provide: DEPLOYMENT_API, + useExisting: DeploymentHttpClient, + }, + // Release Evidence API (runtime HTTP client) + ReleaseEvidenceHttpClient, + { + provide: RELEASE_EVIDENCE_API, + useExisting: ReleaseEvidenceHttpClient, + }, + // Doctor API (runtime HTTP client) + HttpDoctorClient, + { + provide: DOCTOR_API, + useExisting: HttpDoctorClient, + }, + // Witness API (Sprint 20260112_013_FE_witness_ui_wiring) + WitnessHttpClient, + { + provide: WITNESS_API, + useExisting: WitnessHttpClient, + }, + // Notifier API (Bug fix: missing DI providers caused NG0201) + { + provide: NOTIFIER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/notifier', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/notifier`; + } + }, + }, + NotifierApiHttpClient, + MockNotifierClient, + { + provide: NOTIFIER_API, + useExisting: NotifierApiHttpClient, + }, + // Policy Engine API + PolicyEngineHttpClient, + MockPolicyEngineApi, + { + provide: POLICY_ENGINE_API, + useExisting: PolicyEngineHttpClient, + }, + // Trust API + TrustHttpService, + MockTrustApiService, + { + provide: TRUST_API, + useExisting: TrustHttpService, + }, + // ABAC overlay API + AbacOverlayHttpClient, + { + provide: ABAC_OVERLAY_API, + useExisting: AbacOverlayHttpClient, + }, + // Vuln Annotation API (runtime HTTP client) + HttpVulnAnnotationClient, + { + provide: VULN_ANNOTATION_API, + useExisting: HttpVulnAnnotationClient, + }, + // Authority Admin API (admin CRUD for users/roles/clients/tokens/tenants) + { + provide: AUTHORITY_ADMIN_API_BASE_URL, + useValue: '/console/admin', + }, + AuthorityAdminHttpClient, + MockAuthorityAdminClient, + { + provide: AUTHORITY_ADMIN_API, + useExisting: AuthorityAdminHttpClient, + }, + // Security Findings API (findings ledger via gateway) + { + provide: SECURITY_FINDINGS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + SecurityFindingsHttpClient, + MockSecurityFindingsClient, + { + provide: SECURITY_FINDINGS_API, + useExisting: SecurityFindingsHttpClient, + }, + // Security Overview API (aggregated security dashboard data) + SecurityOverviewHttpClient, + MockSecurityOverviewClient, + { + provide: SECURITY_OVERVIEW_API, + useExisting: SecurityOverviewHttpClient, + }, + // Scheduler API (schedule CRUD) + { + provide: SCHEDULER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/scheduler/api/v1/scheduler`; + } + }, + }, + SchedulerHttpClient, + MockSchedulerClient, + { + provide: SCHEDULER_API, + useExisting: SchedulerHttpClient, + }, + // Console Vuln API + ConsoleVulnHttpClient, + MockConsoleVulnClient, + { + provide: CONSOLE_VULN_API, + useExisting: ConsoleVulnHttpClient, + }, + // Reachability API + ReachabilityClient, + MockReachabilityApi, + { + provide: REACHABILITY_API, + useExisting: ReachabilityClient, + }, + // Analytics API + AnalyticsHttpClient, + // Feed Mirror API (Concelier backend via gateway) + { + provide: FEED_MIRROR_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/concelier', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/concelier`; + } + }, + }, + FeedMirrorHttpClient, + { + provide: FEED_MIRROR_API, + useExisting: FeedMirrorHttpClient, + }, + // Attestation Chain API + AttestationChainHttpClient, + { + provide: ATTESTATION_CHAIN_API, + useExisting: AttestationChainHttpClient, + }, + // Console Search API + ConsoleSearchHttpClient, + { + provide: CONSOLE_SEARCH_API, + useExisting: ConsoleSearchHttpClient, + }, + // Policy Governance API + HttpPolicyGovernanceApi, + { + provide: POLICY_GOVERNANCE_API, + useExisting: HttpPolicyGovernanceApi, + }, + // Policy Simulation API + { + provide: POLICY_SIMULATION_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const policyBase = config.config.apiBaseUrls.policy; + return policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; + }, + }, + PolicySimulationHttpClient, + { + provide: POLICY_SIMULATION_API, + useExisting: PolicySimulationHttpClient, + }, + // Graph Platform API + { + provide: GRAPH_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/graph', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/graph`; + } + }, + }, + GraphPlatformHttpClient, + { + provide: GRAPH_PLATFORM_API, + useExisting: GraphPlatformHttpClient, + }, + // Policy Gates API (Policy Gateway backend) + { + provide: POLICY_GATES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } + }, + }, + PolicyGatesHttpClient, + { + provide: POLICY_GATES_API, + useExisting: PolicyGatesHttpClient, + }, + // Release API (Release JobEngine backend) + ReleaseHttpClient, + { + provide: RELEASE_API, + useExisting: ReleaseHttpClient, + }, + // Triage Evidence API + TriageEvidenceHttpClient, + { + provide: TRIAGE_EVIDENCE_API, + useExisting: TriageEvidenceHttpClient, + }, + // Verdict API + HttpVerdictClient, + { + provide: VERDICT_API, + useExisting: HttpVerdictClient, + }, + // Watchlist API + WatchlistHttpClient, + { + provide: WATCHLIST_API, + useExisting: WatchlistHttpClient, + }, + // Evidence API (Evidence Locker backend via gateway) + { + provide: EVIDENCE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/evidence', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/evidence`; + } + }, + }, + EvidenceHttpClient, + { + provide: EVIDENCE_API, + useExisting: EvidenceHttpClient, + }, + // Proof APIs (Manifest, Bundle, Score Replay) + ManifestClient, + ProofBundleClient, + ScoreReplayClient, + { + provide: MANIFEST_API, + useExisting: ManifestClient, + }, + { + provide: PROOF_BUNDLE_API, + useExisting: ProofBundleClient, + }, + { + provide: SCORE_REPLAY_API, + useExisting: ScoreReplayClient, + }, + // SBOM Evidence API + SbomEvidenceService, + { + provide: SBOM_EVIDENCE_API, + useExisting: SbomEvidenceService, + }, + // Replay API + HttpReplayClient, + { + provide: REPLAY_API, + useExisting: HttpReplayClient, + }, + // Score API + HttpScoreClient, + { + provide: SCORE_API, + useExisting: HttpScoreClient, + }, + // Evidence-weighted scoring API + HttpScoringApi, + { + provide: SCORING_API, + useExisting: HttpScoringApi, + }, + // Risk dashboard and fix verification stores + HttpDeltaVerdictApi, + { + provide: DELTA_VERDICT_API, + useExisting: HttpDeltaVerdictApi, + }, + HttpRiskBudgetApi, + { + provide: RISK_BUDGET_API, + useExisting: HttpRiskBudgetApi, + }, + FixVerificationApiClient, + { + provide: FIX_VERIFICATION_API, + useExisting: FixVerificationApiClient, + }, + // AOC API (Attestor + Sources backend via gateway) + { + provide: AOC_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/attestor', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/attestor`; + } + }, + }, + { + provide: AOC_SOURCES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/sources', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/sources`; + } + }, + }, + AocHttpClient, + { provide: AOC_API, useExisting: AocHttpClient }, + + // Identity Provider API (Platform backend via gateway) + { + provide: IDENTITY_PROVIDER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/platform/identity-providers', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/platform/identity-providers`; + } + }, + }, + IdentityProviderApiHttpClient, + MockIdentityProviderClient, + { + provide: IDENTITY_PROVIDER_API, + useExisting: IdentityProviderApiHttpClient, + }, + + // Doctor background services — started from AppComponent to avoid + // NG0200 circular DI during APP_INITIALIZER (Router not yet ready). + DoctorTrendService, + DoctorNotificationService, + ], +}; diff --git a/src/Web/StellaOps.Web/src/app/core/api/notifier.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/notifier.client.spec.ts new file mode 100644 index 000000000..9fe7402a5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/notifier.client.spec.ts @@ -0,0 +1,251 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { AuthSessionStore } from '../auth/auth-session.store'; +import { StellaOpsHeaders } from '../http/stella-ops-headers'; +import { NOTIFIER_API_BASE_URL, NotifierApiHttpClient } from './notifier.client'; + +class FakeAuthSessionStore { + getActiveTenantId(): string | null { + return 'demo-prod'; + } +} + +describe('NotifierApiHttpClient', () => { + let httpMock: HttpTestingController; + let client: NotifierApiHttpClient; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + NotifierApiHttpClient, + { provide: AuthSessionStore, useClass: FakeAuthSessionStore }, + { provide: NOTIFIER_API_BASE_URL, useValue: '/api/v1/notifier' }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + client = TestBed.inject(NotifierApiHttpClient); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('targets the notifier frontdoor rules collection and normalizes raw array responses', () => { + let result: unknown; + client.listRules({ traceId: 'trace-rules' }).subscribe((response) => { + result = response; + }); + + const request = httpMock.expectOne('/api/v1/notifier/rules'); + expect(request.request.method).toBe('GET'); + expect(request.request.headers.get(StellaOpsHeaders.Tenant)).toBe('demo-prod'); + expect(request.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-rules'); + request.flush([ + { + ruleId: 'rule-1', + tenantId: 'demo-prod', + name: 'Critical alerts', + enabled: true, + status: 'active', + match: {}, + actions: [], + createdAt: '2026-03-10T00:00:00Z', + }, + ]); + + expect(result).toEqual({ + items: [ + jasmine.objectContaining({ + ruleId: 'rule-1', + }), + ], + total: 1, + traceId: 'trace-rules', + }); + }); + + it('normalizes notifier envelope responses for channels', () => { + let result: unknown; + client.listChannels({ traceId: 'trace-channels' }).subscribe((response) => { + result = response; + }); + + const request = httpMock.expectOne('/api/v1/notifier/channels'); + expect(request.request.method).toBe('GET'); + request.flush({ + items: [ + { + channelId: 'chn-1', + tenantId: 'demo-prod', + name: 'Slack alerts', + type: 'Slack', + enabled: true, + config: {}, + createdAt: '2026-03-10T00:00:00Z', + }, + ], + count: 1, + }); + + expect(result).toEqual({ + items: [ + jasmine.objectContaining({ + channelId: 'chn-1', + }), + ], + total: 1, + nextPageToken: undefined, + traceId: 'trace-channels', + }); + }); + + it('uses canonical notifier delivery paths', () => { + client.listDeliveries({ limit: 25 }).subscribe(); + const listRequest = httpMock.expectOne((pending) => pending.url === '/api/v1/notifier/deliveries'); + expect(listRequest.request.method).toBe('GET'); + expect(listRequest.request.params.get('limit')).toBe('25'); + listRequest.flush({ items: [], count: 0 }); + + client.getDeliveryStats({ traceId: 'trace-stats' }).subscribe(); + const statsRequest = httpMock.expectOne('/api/v1/notifier/deliveries/stats'); + expect(statsRequest.request.method).toBe('GET'); + expect(statsRequest.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-stats'); + statsRequest.flush({ + totalSent: 0, + totalFailed: 0, + totalThrottled: 0, + totalPending: 0, + avgDeliveryTimeMs: 0, + successRate: 100, + period: 'day', + byChannel: {}, + byEventKind: {}, + }); + }); + + it('uses canonical notifier suppression and escalation paths', () => { + client.listQuietHours().subscribe(); + httpMock.expectOne('/api/v1/notifier/quiet-hours').flush({ items: [], count: 0 }); + + client.listEscalationPolicies().subscribe(); + httpMock.expectOne('/api/v1/notifier/escalation-policies').flush({ items: [], count: 0 }); + + client.listThrottles().subscribe(); + httpMock.expectOne('/api/v1/notifier/throttle-configs').flush({ items: [], count: 0 }); + }); + + it('maps simulation calls onto the notifier single-event endpoint', () => { + let result: unknown; + client + .testRule({ + ruleId: 'rule-1', + eventKind: 'vulnerability.detected', + eventPayload: { cveId: 'CVE-2026-0001' }, + dryRun: true, + }) + .subscribe((response) => { + result = response; + }); + + const request = httpMock.expectOne('/api/v1/notifier/simulate/event'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + eventPayload: { cveId: 'CVE-2026-0001' }, + ruleIds: ['rule-1'], + }); + request.flush({ + simulationId: 'sim-1', + eventResults: [ + { + matched: true, + matchedRules: [ + { + ruleId: 'rule-1', + actions: [ + { + channel: 'chn-1', + template: 'tmpl-1', + }, + ], + }, + ], + }, + ], + }); + + expect(result).toEqual({ + matched: true, + matchedRules: ['rule-1'], + wouldNotify: [ + { + channelId: 'chn-1', + channelName: 'chn-1', + templateId: 'tmpl-1', + digestMode: 'instant', + }, + ], + throttled: false, + throttleReason: undefined, + quietHoursActive: false, + simulationId: 'sim-1', + traceId: jasmine.any(String), + }); + }); + + it('uses notify channel test previews to build admin notification previews', () => { + let result: unknown; + client + .previewNotification({ + channelId: 'chn-1', + templateId: 'tmpl-1', + eventKind: 'vulnerability.detected', + eventPayload: { cveId: 'CVE-2026-0001' }, + }) + .subscribe((response) => { + result = response; + }); + + const getChannelRequest = httpMock.expectOne('/api/v1/notifier/channels/chn-1'); + expect(getChannelRequest.request.method).toBe('GET'); + getChannelRequest.flush({ + channelId: 'chn-1', + tenantId: 'demo-prod', + name: 'Slack alerts', + type: 'Slack', + enabled: true, + config: {}, + createdAt: '2026-03-10T00:00:00Z', + }); + + const previewRequest = httpMock.expectOne('/api/v1/notify/channels/chn-1/test'); + expect(previewRequest.request.method).toBe('POST'); + expect(previewRequest.request.body.templateId).toBe('tmpl-1'); + previewRequest.flush({ + tenantId: 'demo-prod', + channelId: 'chn-1', + queuedAt: '2026-03-10T00:00:00Z', + traceId: 'trace-preview', + preview: { + format: 'Markdown', + title: 'Alert preview', + body: 'Critical vulnerability detected', + }, + }); + + expect(result).toEqual({ + channelType: 'Slack', + subject: 'Alert preview', + body: 'Critical vulnerability detected', + htmlBody: undefined, + format: 'markdown', + variables: { cveId: 'CVE-2026-0001' }, + previewId: 'trace-preview', + traceId: jasmine.any(String), + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/notifier.client.ts b/src/Web/StellaOps.Web/src/app/core/api/notifier.client.ts index 0bb1de2e2..6b243b83b 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/notifier.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/notifier.client.ts @@ -6,10 +6,12 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, InjectionToken, inject } from '@angular/core'; import { Observable, of, delay, throwError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import { generateTraceId } from './trace.util'; +import { StellaOpsHeaders } from '../http/stella-ops-headers'; +import { ChannelTestSendResponse } from './notify.models'; import { NotifierRule, NotifierRuleRequest, @@ -115,6 +117,60 @@ export interface NotifierApi { export const NOTIFIER_API = new InjectionToken('NOTIFIER_API'); export const NOTIFIER_API_BASE_URL = new InjectionToken('NOTIFIER_API_BASE_URL'); +type NotifierCollectionEnvelope = + | readonly T[] + | { + readonly items?: readonly T[]; + readonly total?: number; + readonly count?: number; + readonly nextPageToken?: string | null; + readonly continuationToken?: string | null; + readonly traceId?: string; + }; + +interface NotifierTemplatePreviewEnvelope { + readonly renderedBody?: string; + readonly renderedSubject?: string; + readonly bodyHash?: string; + readonly format?: string; +} + +interface NotifierSimulationActionEnvelope { + readonly channelId?: string; + readonly channel?: string; + readonly templateId?: string; + readonly template?: string; + readonly digestMode?: string; + readonly enabled?: boolean; +} + +interface NotifierSimulationRuleEnvelope { + readonly ruleId?: string; + readonly matchedAt?: string; + readonly actions?: readonly NotifierSimulationActionEnvelope[]; +} + +interface NotifierSimulationEventEnvelope { + readonly matched?: boolean; + readonly matchedRules?: readonly NotifierSimulationRuleEnvelope[]; +} + +interface NotifierSimulationEnvelope { + readonly simulationId?: string; + readonly matched?: boolean; + readonly matchedRules?: readonly string[]; + readonly wouldNotify?: readonly { + readonly channelId?: string; + readonly channelName?: string; + readonly templateId?: string; + readonly digestMode?: string; + }[]; + readonly throttled?: boolean; + readonly throttleReason?: string; + readonly quietHoursActive?: boolean; + readonly eventResults?: readonly NotifierSimulationEventEnvelope[]; +} + /** * HTTP implementation of NotifierApi. */ @@ -122,7 +178,8 @@ export const NOTIFIER_API_BASE_URL = new InjectionToken('NOTIFIER_API_BA export class NotifierApiHttpClient implements NotifierApi { private readonly http = inject(HttpClient); private readonly authSession = inject(AuthSessionStore); - private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notify'; + private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notifier'; + private readonly notifyPreviewBaseUrl = '/api/v1/notify'; // ============================================================================ // Rules @@ -130,11 +187,11 @@ export class NotifierApiHttpClient implements NotifierApi { listRules(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( + return this.http.get>( `${this.baseUrl}/rules`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -187,11 +244,11 @@ export class NotifierApiHttpClient implements NotifierApi { listChannels(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( + return this.http.get>( `${this.baseUrl}/channels`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -208,9 +265,10 @@ export class NotifierApiHttpClient implements NotifierApi { createChannel(request: NotifierChannelRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/channels`, - request, + const channelId = this.createEntityId('chn', request.name); + return this.http.put( + `${this.baseUrl}/channels/${encodeURIComponent(channelId)}`, + this.mapChannelUpsertRequest(request), { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -221,7 +279,7 @@ export class NotifierApiHttpClient implements NotifierApi { const traceId = options.traceId ?? generateTraceId(); return this.http.put( `${this.baseUrl}/channels/${encodeURIComponent(channelId)}`, - request, + this.mapChannelUpsertRequest(request), { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -240,11 +298,15 @@ export class NotifierApiHttpClient implements NotifierApi { testChannel(channelId: string, options: NotifierQueryOptions = {}): Observable<{ success: boolean; message: string }> { const traceId = options.traceId ?? generateTraceId(); - return this.http.post<{ success: boolean; message: string }>( - `${this.baseUrl}/channels/${encodeURIComponent(channelId)}/test`, + return this.http.post( + `${this.notifyPreviewBaseUrl}/channels/${encodeURIComponent(channelId)}/test`, {}, { headers: this.buildHeaders(traceId) } ).pipe( + map((response) => ({ + success: true, + message: response.preview?.title ?? response.preview?.summary ?? 'Preview generated', + })), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -255,11 +317,11 @@ export class NotifierApiHttpClient implements NotifierApi { listTemplates(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( + return this.http.get>( `${this.baseUrl}/templates`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -320,13 +382,13 @@ export class NotifierApiHttpClient implements NotifierApi { if (options.since) params = params.set('since', options.since); if (options.until) params = params.set('until', options.until); if (options.limit) params = params.set('limit', String(options.limit)); - if (options.offset) params = params.set('offset', String(options.offset)); + if (options.pageToken) params = params.set('pageToken', options.pageToken); - return this.http.get( - `${this.baseUrl}/delivery`, + return this.http.get>( + `${this.baseUrl}/deliveries`, { headers: this.buildHeaders(traceId), params } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -334,7 +396,7 @@ export class NotifierApiHttpClient implements NotifierApi { getDelivery(deliveryId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.get( - `${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}`, + `${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -344,7 +406,7 @@ export class NotifierApiHttpClient implements NotifierApi { retryDelivery(deliveryId: string, request?: NotifierRetryRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.post( - `${this.baseUrl}/delivery/${encodeURIComponent(deliveryId)}/retry`, + `${this.baseUrl}/deliveries/${encodeURIComponent(deliveryId)}/retry`, request ?? { deliveryId }, { headers: this.buildHeaders(traceId) } ).pipe( @@ -356,7 +418,7 @@ export class NotifierApiHttpClient implements NotifierApi { getDeliveryStats(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.get( - `${this.baseUrl}/delivery/stats`, + `${this.baseUrl}/deliveries/stats`, { headers: this.buildHeaders(traceId) } ).pipe( map(response => ({ ...response, traceId })), @@ -370,24 +432,45 @@ export class NotifierApiHttpClient implements NotifierApi { testRule(request: NotifierTestRuleRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/simulation/test`, - request, + return this.http.post( + `${this.baseUrl}/simulate/event`, + { + eventPayload: request.eventPayload, + ruleIds: request.ruleId ? [request.ruleId] : [], + }, { headers: this.buildHeaders(traceId) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeSimulationResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } previewNotification(request: NotifierPreviewRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/simulation/preview`, - request, - { headers: this.buildHeaders(traceId) } - ).pipe( - map(response => ({ ...response, traceId })), + const headers = this.buildHeaders(traceId); + return this.getChannel(request.channelId, { ...options, traceId }).pipe( + switchMap((channel) => { + const templateId = + request.templateId || + this.findTemplateIdForPreview(request.ruleId, channel.channelId, request.eventPayload); + + if (!templateId) { + return of(this.buildFallbackPreviewResponse(channel.type, request, traceId)); + } + + return this.http.post( + `${this.notifyPreviewBaseUrl}/channels/${encodeURIComponent(request.channelId)}/test`, + { + templateId, + body: JSON.stringify(request.eventPayload, null, 2), + title: request.eventKind ?? 'Notification preview', + summary: request.eventKind ?? 'Notification preview', + }, + { headers } + ).pipe( + map((response) => this.mapChannelPreviewResponse(channel.type, request, response, traceId)), + ); + }), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -398,11 +481,11 @@ export class NotifierApiHttpClient implements NotifierApi { listQuietHours(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( - `${this.baseUrl}/quiethours`, + return this.http.get>( + `${this.baseUrl}/quiet-hours`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -410,7 +493,7 @@ export class NotifierApiHttpClient implements NotifierApi { getQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.get( - `${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, + `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -419,9 +502,10 @@ export class NotifierApiHttpClient implements NotifierApi { createQuietHours(request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/quiethours`, - request, + const quietHoursId = this.createEntityId('qh', request.name); + return this.http.put( + `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, + this.mapQuietHoursUpsertRequest(request), { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -431,8 +515,8 @@ export class NotifierApiHttpClient implements NotifierApi { updateQuietHours(quietHoursId: string, request: NotifierQuietHoursRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.put( - `${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, - request, + `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, + this.mapQuietHoursUpsertRequest(request), { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -442,7 +526,7 @@ export class NotifierApiHttpClient implements NotifierApi { deleteQuietHours(quietHoursId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.delete( - `${this.baseUrl}/quiethours/${encodeURIComponent(quietHoursId)}`, + `${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -455,11 +539,11 @@ export class NotifierApiHttpClient implements NotifierApi { listOverrides(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( + return this.http.get>( `${this.baseUrl}/overrides`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -512,11 +596,11 @@ export class NotifierApiHttpClient implements NotifierApi { listEscalationPolicies(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( - `${this.baseUrl}/escalation`, + return this.http.get>( + `${this.baseUrl}/escalation-policies`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -524,7 +608,7 @@ export class NotifierApiHttpClient implements NotifierApi { getEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.get( - `${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, + `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -533,8 +617,9 @@ export class NotifierApiHttpClient implements NotifierApi { createEscalationPolicy(request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/escalation`, + const policyId = this.createEntityId('esc', request.name); + return this.http.put( + `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, request, { headers: this.buildHeaders(traceId) } ).pipe( @@ -545,7 +630,7 @@ export class NotifierApiHttpClient implements NotifierApi { updateEscalationPolicy(policyId: string, request: NotifierEscalationPolicyRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.put( - `${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, + `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, request, { headers: this.buildHeaders(traceId) } ).pipe( @@ -556,7 +641,7 @@ export class NotifierApiHttpClient implements NotifierApi { deleteEscalationPolicy(policyId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.delete( - `${this.baseUrl}/escalation/${encodeURIComponent(policyId)}`, + `${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -569,11 +654,11 @@ export class NotifierApiHttpClient implements NotifierApi { listThrottles(options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.get( - `${this.baseUrl}/throttle`, + return this.http.get>( + `${this.baseUrl}/throttle-configs`, { headers: this.buildHeaders(traceId), params: this.buildPaginationParams(options) } ).pipe( - map(response => ({ ...response, traceId })), + map(response => this.normalizeCollectionResponse(response, traceId)), catchError(err => throwError(() => this.mapError(err, traceId))) ); } @@ -581,7 +666,7 @@ export class NotifierApiHttpClient implements NotifierApi { getThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.get( - `${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, + `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -590,8 +675,9 @@ export class NotifierApiHttpClient implements NotifierApi { createThrottle(request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); - return this.http.post( - `${this.baseUrl}/throttle`, + const throttleId = this.createEntityId('thr', request.name); + return this.http.put( + `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, request, { headers: this.buildHeaders(traceId) } ).pipe( @@ -602,7 +688,7 @@ export class NotifierApiHttpClient implements NotifierApi { updateThrottle(throttleId: string, request: NotifierThrottleRequest, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.put( - `${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, + `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, request, { headers: this.buildHeaders(traceId) } ).pipe( @@ -613,7 +699,7 @@ export class NotifierApiHttpClient implements NotifierApi { deleteThrottle(throttleId: string, options: NotifierQueryOptions = {}): Observable { const traceId = options.traceId ?? generateTraceId(); return this.http.delete( - `${this.baseUrl}/throttle/${encodeURIComponent(throttleId)}`, + `${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, { headers: this.buildHeaders(traceId) } ).pipe( catchError(err => throwError(() => this.mapError(err, traceId))) @@ -627,9 +713,9 @@ export class NotifierApiHttpClient implements NotifierApi { private buildHeaders(traceId: string): HttpHeaders { const tenant = this.authSession.getActiveTenantId() || ''; return new HttpHeaders({ - 'X-StellaOps-Tenant': tenant, - 'X-Stella-Trace-Id': traceId, - 'X-Stella-Request-Id': traceId, + [StellaOpsHeaders.Tenant]: tenant, + [StellaOpsHeaders.TraceId]: traceId, + [StellaOpsHeaders.RequestId]: traceId, 'Accept': 'application/json', }); } @@ -645,6 +731,172 @@ export class NotifierApiHttpClient implements NotifierApi { return params; } + private normalizeCollectionResponse( + response: NotifierCollectionEnvelope, + traceId: string, + ): { items: readonly T[]; total: number; nextPageToken?: string; traceId?: string } { + if (Array.isArray(response)) { + return { + items: response, + total: response.length, + traceId, + }; + } + + const envelope = response as Exclude, readonly T[]>; + const items = Array.isArray(envelope.items) ? envelope.items : []; + const total = typeof envelope.total === 'number' + ? envelope.total + : typeof envelope.count === 'number' + ? envelope.count + : items.length; + const nextPageToken = envelope.nextPageToken ?? envelope.continuationToken ?? undefined; + + return { + items, + total, + nextPageToken: nextPageToken ?? undefined, + traceId: envelope.traceId ?? traceId, + }; + } + + private normalizeSimulationResponse(response: NotifierSimulationEnvelope, traceId: string): NotifierTestRuleResponse { + if (Array.isArray(response.wouldNotify) && Array.isArray(response.matchedRules)) { + return { + matched: response.matched ?? response.matchedRules.length > 0, + matchedRules: response.matchedRules, + wouldNotify: response.wouldNotify.map((entry) => ({ + channelId: entry.channelId ?? 'unknown', + channelName: entry.channelName ?? entry.channelId ?? 'unknown', + templateId: entry.templateId, + digestMode: entry.digestMode ?? 'instant', + })), + throttled: response.throttled ?? false, + throttleReason: response.throttleReason, + quietHoursActive: response.quietHoursActive ?? false, + simulationId: response.simulationId ?? traceId, + traceId, + }; + } + + const eventResult = response.eventResults?.[0]; + const matchedRules = (eventResult?.matchedRules ?? []).map((rule) => rule.ruleId ?? 'unknown'); + const wouldNotify = (eventResult?.matchedRules ?? []).flatMap((rule) => + (rule.actions ?? []) + .filter((action) => action.enabled !== false) + .map((action) => ({ + channelId: action.channelId ?? action.channel ?? 'unknown', + channelName: action.channel ?? action.channelId ?? 'unknown', + templateId: action.templateId ?? action.template, + digestMode: action.digestMode ?? 'instant', + })), + ); + + return { + matched: eventResult?.matched ?? matchedRules.length > 0, + matchedRules, + wouldNotify, + throttled: response.throttled ?? false, + throttleReason: response.throttleReason, + quietHoursActive: response.quietHoursActive ?? false, + simulationId: response.simulationId ?? traceId, + traceId, + }; + } + + private mapChannelPreviewResponse( + channelType: NotifierChannel['type'], + request: NotifierPreviewRequest, + response: ChannelTestSendResponse, + traceId: string, + ): NotifierPreviewResponse { + const format = this.normalizePreviewFormat(response.preview?.format); + return { + channelType, + subject: response.preview?.title ?? response.preview?.summary, + body: response.preview?.body ?? JSON.stringify(request.eventPayload, null, 2), + format, + variables: request.eventPayload, + previewId: response.traceId || traceId, + traceId, + }; + } + + private buildFallbackPreviewResponse( + channelType: NotifierChannel['type'], + request: NotifierPreviewRequest, + traceId: string, + ): NotifierPreviewResponse { + return { + channelType, + subject: request.eventKind ?? 'Notification preview', + body: JSON.stringify(request.eventPayload, null, 2), + format: 'text', + variables: request.eventPayload, + previewId: traceId, + traceId, + }; + } + + private normalizePreviewFormat(format: string | undefined): 'text' | 'markdown' | 'html' { + const normalized = format?.toLowerCase(); + if (normalized === 'html') { + return 'html'; + } + if (normalized === 'markdown') { + return 'markdown'; + } + return 'text'; + } + + private mapChannelUpsertRequest(request: NotifierChannelRequest): Record { + const config = request.config as Record; + return { + name: request.name, + description: request.description, + type: request.type, + secretRef: typeof config['secretRef'] === 'string' ? config['secretRef'] : undefined, + endpoint: + (typeof config['webhookUrl'] === 'string' && config['webhookUrl']) || + (typeof config['url'] === 'string' && config['url']) || + undefined, + target: + (typeof config['channel'] === 'string' && config['channel']) || + (Array.isArray(config['toAddresses']) ? config['toAddresses'].join(', ') : undefined) || + (typeof config['routingKey'] === 'string' && config['routingKey']) || + undefined, + }; + } + + private mapQuietHoursUpsertRequest(request: NotifierQuietHoursRequest): Record { + return { + name: request.name, + description: request.description, + windows: request.windows, + exemptions: request.exemptions, + enabled: request.enabled, + }; + } + + private createEntityId(prefix: string, name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + const entropy = generateTraceId().replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(-8); + return `${prefix}-${slug || 'item'}-${entropy}`; + } + + private findTemplateIdForPreview( + _ruleId: string | undefined, + _channelId: string, + _eventPayload: Record, + ): string | undefined { + return undefined; + } + private mapError(err: unknown, traceId: string): Error { return err instanceof Error ? new Error(`[${traceId}] Notifier error: ${err.message}`) diff --git a/src/Web/StellaOps.Web/src/app/core/api/notifier.models.ts b/src/Web/StellaOps.Web/src/app/core/api/notifier.models.ts index 3f0ea77e9..6e1e247fa 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/notifier.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/notifier.models.ts @@ -339,8 +339,10 @@ export interface NotifierTestRuleResponse { /** Preview notification request */ export interface NotifierPreviewRequest { readonly templateId?: string; + readonly ruleId?: string; readonly channelId: string; readonly eventPayload: Record; + readonly eventKind?: string; readonly locale?: string; } diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 59cae4409..7097ce56d 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -588,7 +588,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'admin-notifications', label: 'Notification Admin', - route: '/ops/operations/notifications', + route: '/setup/notifications', icon: 'bell-config', tooltip: 'Configure notification rules, channels, and templates', }, diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts index 8ec5782f1..7765cac10 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts @@ -726,9 +726,16 @@ export class RuleSimulatorComponent implements OnInit { this.previewResult.set(null); try { + const selectedRule = this.rules().find((rule) => rule.ruleId === this.selectedRuleId()); + const selectedAction = + selectedRule?.actions.find((action) => action.channelId === this.previewChannelId) ?? + selectedRule?.actions[0]; const result = await firstValueFrom(this.api.previewNotification({ + ruleId: this.selectedRuleId() || undefined, + templateId: selectedAction?.templateId, channelId: this.previewChannelId, eventPayload: payload, + eventKind: this.eventKind || undefined, })); this.previewResult.set(result); diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index c468e8532..3d5b9d2d8 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -191,8 +191,8 @@ export const OPERATIONS_ROUTES: Routes = [ path: 'notifications', title: 'Notifications', data: { breadcrumb: 'Notifications' }, - loadChildren: () => - import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes), + loadComponent: () => + import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent), }, { path: 'environments', diff --git a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts index 4ed9f0334..ac3f8e4e9 100644 --- a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts @@ -49,32 +49,19 @@ describe('Route surface ownership', () => { expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments'); }); - it('preserves setup notifications redirects into Operations notifications', () => { + it('mounts setup notifications as the admin studio surface', () => { const notificationsRoute = SETUP_ROUTES.find((route) => route.path === 'notifications'); - expect(notificationsRoute?.pathMatch).toBe('prefix'); - expect(typeof notificationsRoute?.redirectTo).toBe('function'); - - const notificationsRedirect = notificationsRoute?.redirectTo; - if (typeof notificationsRedirect !== 'function') { - throw new Error('Setup notifications route must expose a redirect function.'); - } - - const target = invokeRedirect(notificationsRedirect, { - params: {}, - queryParams: { tenant: 'demo-prod', regions: 'us-east' }, - fragment: 'channels', - }); - - expect(target).toBe('/ops/operations/notifications?tenant=demo-prod®ions=us-east#channels'); + expect(typeof notificationsRoute?.loadChildren).toBe('function'); + expect(notificationsRoute?.title).toBe('Notifications'); }); - it('mounts Operations ownership for notifications and environments', () => { + it('mounts Operations ownership for operator notifications and environments', () => { const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications'); const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments'); const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId'); - expect(typeof notificationsRoute?.loadChildren).toBe('function'); + expect(typeof notificationsRoute?.loadComponent).toBe('function'); expect(typeof environmentsRoute?.loadComponent).toBe('function'); expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); }); diff --git a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts index ad9c1d62f..9c5c9125e 100644 --- a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts @@ -1,5 +1,4 @@ -import { inject } from '@angular/core'; -import { Router, Routes } from '@angular/router'; +import { Routes } from '@angular/router'; export const SETUP_ROUTES: Routes = [ { @@ -27,17 +26,12 @@ export const SETUP_ROUTES: Routes = [ (m) => m.BrandingSettingsPageComponent, ), }, - // Redirect to consolidated notifications under Operations { path: 'notifications', - pathMatch: 'prefix', - redirectTo: ({ queryParams, fragment }) => { - const router = inject(Router); - const target = router.parseUrl('/ops/operations/notifications'); - target.queryParams = { ...queryParams }; - target.fragment = fragment ?? null; - return target; - }, + title: 'Notifications', + data: { breadcrumb: 'Notifications' }, + loadChildren: () => + import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes), }, { path: 'usage',