diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json
index 4c3e4d323..730779f35 100644
--- a/devops/compose/router-gateway-local.json
+++ b/devops/compose/router-gateway-local.json
@@ -32,7 +32,6 @@
"policy",
"policy-engine",
"notify",
- "notifier",
"scanner",
"findings-ledger",
"integrations",
@@ -75,8 +74,8 @@
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
- { "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
- { "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
+ { "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/policy$1" },
+ { "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/workflow(.*)", "IsRegex": true, "TranslatesTo": "http://workflow.stella-ops.local/api/workflow$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/v1/workflows$1" },
@@ -91,8 +90,8 @@
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
{ "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/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/deliveries$1" },
+ { "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notify.stella-ops.local/api/v2/notify/$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notify/(digest-schedules|quiet-hours|throttle-configs|simulate|escalation-policies|localizations|incidents)(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/notify/$1$2" },
{ "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" },
@@ -112,7 +111,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
- { "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
+ { "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
@@ -130,9 +129,9 @@
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
- { "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
- { "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
- { "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
+ { "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
+ { "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
+ { "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://release-orchestrator.stella-ops.local/v1/runs$1" },
diff --git a/devops/docker/services-matrix.env b/devops/docker/services-matrix.env
index 8f8fdcdc8..a4a7be8ba 100644
--- a/devops/docker/services-matrix.env
+++ b/devops/docker/services-matrix.env
@@ -33,8 +33,8 @@ vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.Vex
api|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
# ── Slot 14: Policy Engine ──────────────────────────────────────────────────────
policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080
-# ── Slot 15: Policy Gateway ─────────────────────────────────────────────────────
-policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
+# ── Slot 15: Policy Gateway (MERGED into policy-engine, Slot 14) ───────────────
+# policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
# ── Slot 16: RiskEngine ─────────────────────────────────────────────────────────
riskengine-web|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.csproj|StellaOps.RiskEngine.WebService|8080
riskengine-worker|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.RiskEngine.Worker/StellaOps.RiskEngine.Worker.csproj|StellaOps.RiskEngine.Worker|8080
@@ -64,8 +64,8 @@ doctor-web|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Docto
doctor-scheduler|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Doctor.Scheduler/StellaOps.Doctor.Scheduler.csproj|StellaOps.Doctor.Scheduler|8080
# ── Slot 27: OpsMemory ──────────────────────────────────────────────────────────
opsmemory-web|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.csproj|StellaOps.OpsMemory.WebService|8080
-# ── Slot 28: Notifier ───────────────────────────────────────────────────────────
-notifier-web|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.csproj|StellaOps.Notifier.WebService|8080
+# ── Slot 28: Notifier (web merged into notify-web; worker stays) ────────────────
+# notifier-web: MERGED into notify-web
notifier-worker|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj|StellaOps.Notifier.Worker|8080
# ── Slot 29: Notify ─────────────────────────────────────────────────────────────
notify-web|devops/docker/Dockerfile.hardened.template|src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj|StellaOps.Notify.WebService|8080
diff --git a/docs/modules/notify/architecture-detail.md b/docs/modules/notify/architecture-detail.md
index 6cf4ddd39..790ca61ee 100644
--- a/docs/modules/notify/architecture-detail.md
+++ b/docs/modules/notify/architecture-detail.md
@@ -33,9 +33,9 @@ Tenant API│ REST + gRPC WIP │ │ rules/channels│
└─────────────┘ └──────────────────┘
```
-- **2025-11-02 decision — module boundaries.** Keep `src/Notify/` as the shared notification toolkit (engine, storage, queue, connectors) that multiple hosts can consume. `src/Notifier/` remains the Notifications Studio runtime (WebService + Worker) composed from those libraries. Do not collapse the directories until a packaging RFC covers build impacts, offline kit parity, and imposed-rule propagation.
-- **WebService** hosts REST endpoints (`/channels`, `/rules`, `/templates`, `/deliveries`, `/digests`, `/stats`) and handles schema normalisation, validation, and Authority enforcement.
-- **Worker** subscribes to the platform event bus, evaluates rules per tenant, applies throttles/digests, renders payloads, writes ledger entries, and invokes connectors.
+- **2025-11-02 decision — module boundaries.** Keep `src/Notify/` as the shared notification toolkit (engine, storage, queue, connectors) that multiple hosts can consume. `src/Notifier/` retains the Worker (delivery engine) while the Notifier WebService has been **merged into `src/Notify/StellaOps.Notify.WebService`** (2026-04-08). The `notifier.stella-ops.local` hostname is now a DNS alias on the `notify-web` container.
+- **Notify WebService** (`src/Notify/StellaOps.Notify.WebService`) hosts all REST endpoints — both original Notify v1 (`/channels`, `/rules`, `/templates`, `/deliveries`, `/digests`, `/stats`) and merged Notifier v2 (`/api/v2/notify/*` escalation, incident, simulation, storm-breaker, etc.) — with schema normalisation, validation, and Authority enforcement.
+- **Notifier Worker** (`src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker`) subscribes to the platform event bus, evaluates rules per tenant, applies throttles/digests, renders payloads, writes ledger entries, and invokes connectors. It remains a separate container.
- **Plug-ins** live under `plugins/notify/` and are loaded deterministically at service start (`orderedPlugins` list). Each implements connector contracts and optional health/test-preview providers.
Both services share options via `notify.yaml` (see `etc/notify.yaml.sample`). For dev/test scenarios, an in-memory repository exists but production requires PostgreSQL + Valkey/NATS for durability and coordination.
diff --git a/docs/modules/notify/architecture.md b/docs/modules/notify/architecture.md
index f71b58aec..02198337e 100644
--- a/docs/modules/notify/architecture.md
+++ b/docs/modules/notify/architecture.md
@@ -543,8 +543,10 @@ Offline Kit builder and include:
These files are copied automatically by `ops/offline-kit/build_offline_kit.py`
via `copy_bootstrap_configs`. Operators mount the configuration and secret into
-the `StellaOps.Notifier.WebService` container (Compose or Kubernetes) to keep
-sealed-mode roll-outs reproducible.
+the `StellaOps.Notify.WebService` container (Compose or Kubernetes) to keep
+sealed-mode roll-outs reproducible. (Notifier WebService was merged into
+Notify WebService; the `notifier.stella-ops.local` hostname is now an alias
+on the `notify-web` container.)
---
diff --git a/docs/modules/router/webservices-valkey-rollout-matrix.md b/docs/modules/router/webservices-valkey-rollout-matrix.md
index 2b551f722..00f18387e 100644
--- a/docs/modules/router/webservices-valkey-rollout-matrix.md
+++ b/docs/modules/router/webservices-valkey-rollout-matrix.md
@@ -33,14 +33,13 @@ Legend:
| _(gateway.stella-ops.local — removed, consolidated into router-gateway)_ | — | — | — | — | Legacy gateway container eliminated; all traffic served by router-gateway (slot 0). | N/A |
| integrations.stella-ops.local | integrations-web | /api/v1/integrations, /integrations | A | Developer + Test Automation (Wave A) | Migrate API prefix first, then root compatibility path. | Route type revert + `INTEGRATIONS_ROUTER_ENABLED=false` (RMW-03). |
| issuerdirectory.stella-ops.local | issuer-directory | /issuerdirectory | B | Developer + Test Automation (Wave B) | Migrate route in trust-plane wave with issuer/auth verification checks. | Route type revert + `ISSUERDIRECTORY_ROUTER_ENABLED=false` (RMW-03). |
-| notifier.stella-ops.local | notifier-web | /api/v1/notifier, /notifier | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `NOTIFIER_ROUTER_ENABLED=false` (RMW-03). |
-| notify.stella-ops.local | notify-web | /api/v1/notify, /notify | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `NOTIFY_ROUTER_ENABLED=false` (RMW-03). |
+| notify.stella-ops.local (+ notifier.stella-ops.local alias) | notify-web | /api/v1/notify, /notify, /api/v1/notifier, /notifier | D | Developer + Test Automation (Wave D) | Merged: notifier-web folded into notify-web. | Route type revert + `NOTIFY_ROUTER_ENABLED=false` (RMW-03). |
| opsmemory.stella-ops.local | opsmemory-web | /api/v1/opsmemory, /opsmemory | A | Developer + Test Automation (Wave A) | Migrate API prefix first, then root compatibility path. | Route type revert + `OPSMEMORY_ROUTER_ENABLED=false` (RMW-03). |
| jobengine.stella-ops.local | orchestrator | /api/approvals, /api/jobengine, /api/release-orchestrator, /api/releases, /api/v1/jobengine, /api/v1/release-orchestrator, /api/v1/workflows, /orchestrator, /v1/runs | C | Developer + Test Automation (Wave C) | Migrate all API/v1 and v1 routes first; keep root compatibility path until control-plane acceptance. | Route type revert + `ORCHESTRATOR_ROUTER_ENABLED=false` (RMW-03). |
| packsregistry.stella-ops.local | packsregistry-web | /packsregistry | A | Developer + Test Automation (Wave A) | Add API-form endpoint mapping if required, then migrate root compatibility route. | Route type revert + `PACKSREGISTRY_ROUTER_ENABLED=false` (RMW-03). |
| platform.stella-ops.local | platform | /api, /api/admin, /api/analytics, /api/v1/authority/quotas, /api/v1/gateway/rate-limits, /api/v1/platform, /envsettings.json, /platform | C | Developer + Test Automation (Wave C) | Migrate API prefixes to Microservice; keep `/platform` and `/envsettings.json` reverse proxy for static/bootstrap behavior. | Route type revert + `PLATFORM_ROUTER_ENABLED=false` (RMW-03). |
| policy-engine.stella-ops.local | policy-engine | /api/risk, /api/risk-budget, /api/v1/determinization, /policyEngine | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep root compatibility path until control-plane verification completes. | Route type revert + `POLICY_ENGINE_ROUTER_ENABLED=false` (RMW-03). |
-| policy-gateway.stella-ops.local | policy | /api/cvss, /api/exceptions, /api/gate, /api/policy, /api/v1/governance, /api/v1/policy, /policy, /policyGateway | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep `/policy` and `/policyGateway` compatibility paths until final cutover. | Route type revert + `POLICY_GATEWAY_ROUTER_ENABLED=false` (RMW-03). |
+| ~~policy-gateway.stella-ops.local~~ | ~~policy~~ | _Merged into policy-engine above_ | - | - | Gateway merged into policy-engine. All routes now served by policy-engine. | - |
| reachgraph.stella-ops.local | reachgraph-web | /api/v1/reachability, /reachgraph | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `REACHGRAPH_ROUTER_ENABLED=false` (RMW-03). |
| remediation.stella-ops.local | — (not in compose snapshot) | — (no ReverseProxy route in 2026-02-21 snapshot) | C | Developer + Test Automation (Wave C) | `StellaOps.Remediation.WebService` exists, but router/compose mapping is missing. Add explicit remediation API route inventory and then migrate to Microservice route type in control-plane wave. | Missing rollback key; add `REMEDIATION_ROUTER_ENABLED` once route is added. |
| registry-token.stella-ops.local | registry-token | /registryTokenservice | A | Developer + Test Automation (Wave A) | Migrate compatibility route with token flow validation in Wave A. | Route type revert + `REGISTRY_TOKEN_ROUTER_ENABLED=false` (RMW-03). |
diff --git a/docs/technical/architecture/component-map.md b/docs/technical/architecture/component-map.md
index 00c584b7f..a4588ec60 100644
--- a/docs/technical/architecture/component-map.md
+++ b/docs/technical/architecture/component-map.md
@@ -40,8 +40,9 @@ Concise descriptions of every top-level component under `src/`, summarising the
- **TimelineIndexer** — Builds timelines of evidence/events for forensics and audit tooling (`docs/modules/timeline-indexer/guides/timeline.md`).
## Notification & UI
-- **Notifier** — Current notifications studio (WebService + Worker under `src/Notifier/StellaOps.Notifier`) delivering rule evaluation, digests, incidents, and channel plug-ins. Built on the shared `StellaOps.Notify.*` libraries; see `docs/modules/notify/overview.md` and `src/Notifier/StellaOps.Notifier/docs/NOTIFY-SVC-38-001-FOUNDATIONS.md`.
-- **Notify (shared libraries / archival hosts)** — The former `StellaOps.Notify.WebService|Worker` hosts were archived on 2025-10-26. The directory now provides the reusable engine, storage, queue, and connector plug-ins that Notifier composes. Legacy guidance in `docs/modules/notify/architecture.md` remains as migration context until the Notifications Studio docs fully supersede it.
+- **Notify** — Unified notification service (`src/Notify/StellaOps.Notify.WebService`) hosting both v1 channel/rule/template APIs and merged v2 Notifier endpoints (escalation, incident, simulation, storm-breaker, etc.). The `notifier.stella-ops.local` hostname is a DNS alias on the `notify-web` container. Built on the shared `StellaOps.Notify.*` libraries; see `docs/modules/notify/architecture.md`.
+- **Notifier Worker** — Delivery engine (`src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker`) subscribing to the platform event bus, evaluating rules, rendering payloads, and invoking channel connectors. Remains a separate container.
+
- **UI** — Angular console surfacing scans, policy authoring, VEX evidence, runtime posture, and admin flows. Talks to Web gateway, Authority, Policy, Concelier, Scheduler, Notify, etc. (`docs/modules/ui/architecture.md`).
- **DevPortal** — Developer onboarding portal consuming Api definitions, CLI samples, and Authority auth flows (`docs/modules/devops/architecture.md`, dev portal sections).
diff --git a/docs/technical/architecture/module-matrix.md b/docs/technical/architecture/module-matrix.md
index 703665bc3..9eba170c4 100644
--- a/docs/technical/architecture/module-matrix.md
+++ b/docs/technical/architecture/module-matrix.md
@@ -82,8 +82,8 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum
| Module | Path | Purpose | WebService | Worker | Storage |
|--------|------|---------|------------|--------|---------|
| **JobEngine** | `src/JobEngine/` | Workflow orchestration, scheduling, task execution, pack registry. Includes Scheduler, TaskRunner, PacksRegistry (Sprint 208); renamed from Orchestrator (Sprint 221). | Yes | Yes | PostgreSQL (`orchestrator`, `scheduler`) |
-| **Notify** | `src/Notify/` | Notification toolkit (Email, Slack, Teams, Webhooks) - shared libraries. Boundary preserved with Notifier (Sprint 209). | Library | N/A | N/A |
-| **Notifier** | `src/Notifier/` | Notifications Studio host (WebService + Worker). Boundary preserved with Notify (Sprint 209). | Yes | Yes | PostgreSQL (`notify`) |
+| **Notify** | `src/Notify/` | Unified notification service (shared libraries + merged WebService). Notifier WebService merged into Notify WebService (2026-04-08). | Yes | N/A | PostgreSQL (`notify`) |
+| **Notifier** | `src/Notifier/` | Notifier Worker (delivery engine). WebService merged into Notify (2026-04-08). | N/A | Yes | PostgreSQL (`notify`) |
| **Timeline** | `src/Timeline/` | Timeline query, event indexing, and replay. Includes TimelineIndexer (Sprint 210). | Yes | No | PostgreSQL |
| **Replay** | `src/Replay/` | Deterministic replay engine | Yes | No | PostgreSQL |
diff --git a/docs/technical/architecture/port-registry.md b/docs/technical/architecture/port-registry.md
index f5ff6294b..7656461ea 100644
--- a/docs/technical/architecture/port-registry.md
+++ b/docs/technical/architecture/port-registry.md
@@ -30,7 +30,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o
| 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` |
| 13 | 10130 | 10131 | VulnExplorer | `vulnexplorer.stella-ops.local` | `src/Findings/StellaOps.VulnExplorer.Api` | `STELLAOPS_VULNEXPLORER_URL` |
| 14 | 10140 | 10141 | Policy Engine | `policy-engine.stella-ops.local` | `src/Policy/StellaOps.Policy.Engine` | `STELLAOPS_POLICY_ENGINE_URL` |
-| 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` |
+| 15 | 10150 | 10151 | ~~Policy Gateway~~ (merged into Policy Engine, Slot 14) | `policy-gateway.stella-ops.local` -> `policy-engine.stella-ops.local` | _removed_ | _removed_ |
| 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` |
| 17 | 10170 | 10171 | Orchestrator | `jobengine.stella-ops.local` | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `STELLAOPS_JOBENGINE_URL` |
| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` |
@@ -43,7 +43,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o
| 25 | 10250 | 10251 | Findings Ledger | `findings.stella-ops.local` | `src/Findings/StellaOps.Findings.Ledger.WebService` | `STELLAOPS_FINDINGS_LEDGER_URL` |
| 26 | 10260 | 10261 | Doctor | `doctor.stella-ops.local` | `src/Doctor/StellaOps.Doctor.WebService` | `STELLAOPS_DOCTOR_URL` |
| 27 | 10270 | 10271 | OpsMemory | `opsmemory.stella-ops.local` | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `STELLAOPS_OPSMEMORY_URL` |
-| 28 | 10280 | 10281 | Notifier | `notifier.stella-ops.local` | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `STELLAOPS_NOTIFIER_URL` |
+| 28 | 10280 | 10281 | _(Notifier WebService merged into Notify)_ | `notifier.stella-ops.local` (alias) | _(see Notify)_ | `STELLAOPS_NOTIFIER_URL` |
| 29 | 10290 | 10291 | Notify | `notify.stella-ops.local` | `src/Notify/StellaOps.Notify.WebService` | `STELLAOPS_NOTIFY_URL` |
| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` |
| 31 | 10310 | 10311 | SmRemote | `smremote.stella-ops.local` | `src/SmRemote/StellaOps.SmRemote.Service` | `STELLAOPS_SMREMOTE_URL` |
@@ -125,7 +125,7 @@ Add the following to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on
127.1.0.12 vexlens.stella-ops.local
127.1.0.13 vulnexplorer.stella-ops.local
127.1.0.14 policy-engine.stella-ops.local
-127.1.0.15 policy-gateway.stella-ops.local
+127.1.0.14 policy-gateway.stella-ops.local # alias -> policy-engine (merged)
127.1.0.16 riskengine.stella-ops.local
127.1.0.17 jobengine.stella-ops.local
127.1.0.18 taskrunner.stella-ops.local
diff --git a/docs/technical/architecture/webservice-catalog.md b/docs/technical/architecture/webservice-catalog.md
index 8b6b2ebec..6617c89af 100644
--- a/docs/technical/architecture/webservice-catalog.md
+++ b/docs/technical/architecture/webservice-catalog.md
@@ -36,8 +36,7 @@ This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime
| JobEngine | PacksRegistry | `packsregistry.stella-ops.local` | Pack/provenance/attestation registry APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `src/JobEngine` |
| JobEngine | Scheduler | `scheduler.stella-ops.local` | Schedule/run planning and event APIs. | postgres | `src/JobEngine/StellaOps.Scheduler.WebService` | `src/JobEngine` |
| JobEngine | TaskRunner | `taskrunner.stella-ops.local` | Task execution, run state/log, approval, and artifact APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `src/JobEngine` |
-| Notifier | Notifier | `notifier.stella-ops.local` | Escalation and incident notification APIs. | postgres | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `src/Notifier` |
-| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template and delivery APIs. | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` |
+| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template, delivery, escalation, incident, and simulation APIs (merged from Notifier). | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` |
| Platform | Platform | `platform.stella-ops.local` | Console aggregation, setup, admin, and read-model APIs. | postgres | `src/Platform/StellaOps.Platform.WebService` | `src/Platform` |
| ReachGraph | ReachGraph | `reachgraph.stella-ops.local` | Reachability graph and CVE mapping APIs. | postgres | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `src/ReachGraph` |
| Remediation | Remediation | `remediation.stella-ops.local` | Remediation source, registry, and match APIs. | postgres | `src/Remediation/StellaOps.Remediation.WebService` | `src/Remediation` |
diff --git a/src/Notify/StellaOps.Notify.WebService/Constants/NotifierPolicies.cs b/src/Notify/StellaOps.Notify.WebService/Constants/NotifierPolicies.cs
new file mode 100644
index 000000000..45c22ceae
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Constants/NotifierPolicies.cs
@@ -0,0 +1,34 @@
+namespace StellaOps.Notify.WebService.Constants;
+
+///
+/// Named authorization policy constants for Notifier API endpoints.
+/// These correspond to scopes defined in .
+///
+public static class NotifierPolicies
+{
+ ///
+ /// Read-only access to channels, rules, templates, delivery history, and observability.
+ /// Maps to scope: notify.viewer
+ ///
+ public const string NotifyViewer = "notify.viewer";
+
+ ///
+ /// Rule management, channel operations, template authoring, delivery actions, and simulation.
+ /// Maps to scope: notify.operator
+ ///
+ public const string NotifyOperator = "notify.operator";
+
+ ///
+ /// Administrative control over security configuration, signing key rotation,
+ /// tenant isolation grants, retention policies, and platform-wide settings.
+ /// Maps to scope: notify.admin
+ ///
+ public const string NotifyAdmin = "notify.admin";
+
+ ///
+ /// Escalation-specific actions: starting, escalating, stopping incidents and
+ /// managing escalation policies and on-call schedules.
+ /// Maps to scope: notify.escalate
+ ///
+ public const string NotifyEscalate = "notify.escalate";
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/AttestationEventRequest.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/AttestationEventRequest.cs
new file mode 100644
index 000000000..8e282916c
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/AttestationEventRequest.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+public sealed record AttestationEventRequest
+{
+ public Guid EventId { get; init; }
+
+ ///
+ /// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
+ ///
+ public string? Kind { get; init; }
+
+ public string? Actor { get; init; }
+
+ public DateTimeOffset? Timestamp { get; init; }
+
+ public JsonObject? Payload { get; init; }
+
+ public IDictionary? Attributes { get; init; }
+
+ public string? ResumeToken { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/ChannelContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/ChannelContracts.cs
new file mode 100644
index 000000000..38b788322
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/ChannelContracts.cs
@@ -0,0 +1,16 @@
+using StellaOps.Notify.Models;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request for creating or updating a channel.
+///
+public sealed record ChannelUpsertRequest
+{
+ public string? Name { get; init; }
+ public NotifyChannelType? Type { get; init; }
+ public string? Endpoint { get; init; }
+ public string? Target { get; init; }
+ public string? SecretRef { get; init; }
+ public string? Description { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/DeadLetterContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/DeadLetterContracts.cs
new file mode 100644
index 000000000..0e67b4dd9
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/DeadLetterContracts.cs
@@ -0,0 +1,137 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to enqueue a dead-letter entry.
+///
+public sealed record EnqueueDeadLetterRequest
+{
+ public required string DeliveryId { get; init; }
+ public required string EventId { get; init; }
+ public required string ChannelId { get; init; }
+ public required string ChannelType { get; init; }
+ public required string FailureReason { get; init; }
+ public string? FailureDetails { get; init; }
+ public int AttemptCount { get; init; }
+ public DateTimeOffset? LastAttemptAt { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+ public string? OriginalPayload { get; init; }
+}
+
+///
+/// Response for dead-letter entry operations.
+///
+public sealed record DeadLetterEntryResponse
+{
+ public required string EntryId { get; init; }
+ public required string TenantId { get; init; }
+ public required string DeliveryId { get; init; }
+ public required string EventId { get; init; }
+ public required string ChannelId { get; init; }
+ public required string ChannelType { get; init; }
+ public required string FailureReason { get; init; }
+ public string? FailureDetails { get; init; }
+ public required int AttemptCount { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public DateTimeOffset? LastAttemptAt { get; init; }
+ public required string Status { get; init; }
+ public int RetryCount { get; init; }
+ public DateTimeOffset? LastRetryAt { get; init; }
+ public string? Resolution { get; init; }
+ public string? ResolvedBy { get; init; }
+ public DateTimeOffset? ResolvedAt { get; init; }
+}
+
+///
+/// Request to list dead-letter entries.
+///
+public sealed record ListDeadLetterRequest
+{
+ public string? Status { get; init; }
+ public string? ChannelId { get; init; }
+ public string? ChannelType { get; init; }
+ public DateTimeOffset? Since { get; init; }
+ public DateTimeOffset? Until { get; init; }
+ public int Limit { get; init; } = 50;
+ public int Offset { get; init; }
+}
+
+///
+/// Response for listing dead-letter entries.
+///
+public sealed record ListDeadLetterResponse
+{
+ public required IReadOnlyList Entries { get; init; }
+ public required int TotalCount { get; init; }
+}
+
+///
+/// Request to retry dead-letter entries.
+///
+public sealed record RetryDeadLetterRequest
+{
+ public required IReadOnlyList EntryIds { get; init; }
+}
+
+///
+/// Response for retry operations.
+///
+public sealed record RetryDeadLetterResponse
+{
+ public required IReadOnlyList Results { get; init; }
+ public required int SuccessCount { get; init; }
+ public required int FailureCount { get; init; }
+}
+
+///
+/// Individual retry result.
+///
+public sealed record DeadLetterRetryResultItem
+{
+ public required string EntryId { get; init; }
+ public required bool Success { get; init; }
+ public string? Error { get; init; }
+ public DateTimeOffset? RetriedAt { get; init; }
+ public string? NewDeliveryId { get; init; }
+}
+
+///
+/// Request to resolve a dead-letter entry.
+///
+public sealed record ResolveDeadLetterRequest
+{
+ public required string Resolution { get; init; }
+ public string? ResolvedBy { get; init; }
+}
+
+///
+/// Response for dead-letter statistics.
+///
+public sealed record DeadLetterStatsResponse
+{
+ public required int TotalCount { get; init; }
+ public required int PendingCount { get; init; }
+ public required int RetryingCount { get; init; }
+ public required int RetriedCount { get; init; }
+ public required int ResolvedCount { get; init; }
+ public required int ExhaustedCount { get; init; }
+ public required IReadOnlyDictionary ByChannel { get; init; }
+ public required IReadOnlyDictionary ByReason { get; init; }
+ public DateTimeOffset? OldestEntryAt { get; init; }
+ public DateTimeOffset? NewestEntryAt { get; init; }
+}
+
+///
+/// Request to purge expired entries.
+///
+public sealed record PurgeDeadLetterRequest
+{
+ public int MaxAgeDays { get; init; } = 30;
+}
+
+///
+/// Response for purge operation.
+///
+public sealed record PurgeDeadLetterResponse
+{
+ public required int PurgedCount { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/DeliveryContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/DeliveryContracts.cs
new file mode 100644
index 000000000..4a516797c
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/DeliveryContracts.cs
@@ -0,0 +1,109 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// API contracts for delivery history and retry endpoints.
+/// Sprint: SPRINT_20251229_018b_FE_notification_delivery_audit
+/// Task: NOTIFY-016
+///
+
+///
+/// Response for delivery listing.
+///
+public sealed record DeliveryListResponse
+{
+ public required IReadOnlyList Items { get; init; }
+ public required int Total { get; init; }
+ public string? ContinuationToken { get; init; }
+}
+
+///
+/// Individual delivery response.
+///
+public sealed record DeliveryResponse
+{
+ public required string DeliveryId { get; init; }
+ public required string TenantId { get; init; }
+ public required string RuleId { get; init; }
+ public required string ChannelId { get; init; }
+ public string? EventId { get; init; }
+ public string? EventKind { get; init; }
+ public string? Target { get; init; }
+ public required string Status { get; init; }
+ public required IReadOnlyList Attempts { get; init; }
+ public required int RetryCount { get; init; }
+ public string? NextRetryAt { get; init; }
+ public string? Subject { get; init; }
+ public string? ErrorMessage { get; init; }
+ public required string CreatedAt { get; init; }
+ public string? SentAt { get; init; }
+ public string? CompletedAt { get; init; }
+}
+
+///
+/// Individual delivery attempt response.
+///
+public sealed record DeliveryAttemptResponse
+{
+ public required int AttemptNumber { get; init; }
+ public required string Timestamp { get; init; }
+ public required string Status { get; init; }
+ public int? StatusCode { get; init; }
+ public string? ErrorMessage { get; init; }
+ public int? ResponseTimeMs { get; init; }
+}
+
+///
+/// Request to retry a failed delivery.
+///
+public sealed record DeliveryRetryRequest
+{
+ public string? ForceChannel { get; init; }
+ public bool BypassThrottle { get; init; }
+ public string? Reason { get; init; }
+}
+
+///
+/// Response from retry operation.
+///
+public sealed record DeliveryRetryResponse
+{
+ public required string DeliveryId { get; init; }
+ public required bool Retried { get; init; }
+ public required int NewAttemptNumber { get; init; }
+ public required string ScheduledAt { get; init; }
+ public string? Message { get; init; }
+}
+
+///
+/// Delivery statistics response.
+///
+public sealed record DeliveryStatsResponse
+{
+ public required int TotalSent { get; init; }
+ public required int TotalFailed { get; init; }
+ public required int TotalThrottled { get; init; }
+ public required int TotalPending { get; init; }
+ public required double AvgDeliveryTimeMs { get; init; }
+ public required double SuccessRate { get; init; }
+ public required string Period { get; init; }
+ public required IReadOnlyDictionary ByChannel { get; init; }
+ public required IReadOnlyDictionary ByEventKind { get; init; }
+}
+
+///
+/// Statistics by channel.
+///
+public sealed record ChannelStatsResponse
+{
+ public required int Sent { get; init; }
+ public required int Failed { get; init; }
+}
+
+///
+/// Statistics by event kind.
+///
+public sealed record EventKindStatsResponse
+{
+ public required int Sent { get; init; }
+ public required int Failed { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/EscalationContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/EscalationContracts.cs
new file mode 100644
index 000000000..ee1e77c94
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/EscalationContracts.cs
@@ -0,0 +1,150 @@
+
+using StellaOps.Notify.Models;
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to create/update an escalation policy.
+///
+public sealed record EscalationPolicyUpsertRequest
+{
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public ImmutableArray Levels { get; init; }
+ public int? RepeatCount { get; init; }
+ public bool? Enabled { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
+
+///
+/// Escalation level configuration.
+///
+public sealed record EscalationLevelRequest
+{
+ public int Order { get; init; }
+ public TimeSpan EscalateAfter { get; init; }
+ public ImmutableArray Targets { get; init; }
+}
+
+///
+/// Escalation target configuration.
+///
+public sealed record EscalationTargetRequest
+{
+ public string? Type { get; init; }
+ public string? TargetId { get; init; }
+}
+
+///
+/// Request to start an escalation for an incident.
+///
+public sealed record StartEscalationRequest
+{
+ public string? IncidentId { get; init; }
+ public string? PolicyId { get; init; }
+}
+
+///
+/// Request to acknowledge an escalation.
+///
+public sealed record AcknowledgeEscalationRequest
+{
+ public string? StateIdOrIncidentId { get; init; }
+ public string? AcknowledgedBy { get; init; }
+}
+
+///
+/// Request to resolve an escalation.
+///
+public sealed record ResolveEscalationRequest
+{
+ public string? StateIdOrIncidentId { get; init; }
+ public string? ResolvedBy { get; init; }
+}
+
+///
+/// Request to create/update an on-call schedule.
+///
+public sealed record OnCallScheduleUpsertRequest
+{
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public string? TimeZone { get; init; }
+ public ImmutableArray Layers { get; init; }
+ public bool? Enabled { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
+
+///
+/// On-call layer configuration.
+///
+public sealed record OnCallLayerRequest
+{
+ public string? LayerId { get; init; }
+ public string? Name { get; init; }
+ public int Priority { get; init; }
+ public DateTimeOffset RotationStartsAt { get; init; }
+ public TimeSpan RotationInterval { get; init; }
+ public ImmutableArray Participants { get; init; }
+ public OnCallRestrictionRequest? Restrictions { get; init; }
+}
+
+///
+/// On-call participant configuration.
+///
+public sealed record OnCallParticipantRequest
+{
+ public string? UserId { get; init; }
+ public string? Name { get; init; }
+ public string? Email { get; init; }
+ public ImmutableArray ContactMethods { get; init; }
+}
+
+///
+/// Contact method configuration.
+///
+public sealed record ContactMethodRequest
+{
+ public string? Type { get; init; }
+ public string? Address { get; init; }
+}
+
+///
+/// On-call restriction configuration.
+///
+public sealed record OnCallRestrictionRequest
+{
+ public string? Type { get; init; }
+ public ImmutableArray TimeRanges { get; init; }
+}
+
+///
+/// Time range for on-call restrictions.
+///
+public sealed record TimeRangeRequest
+{
+ public TimeOnly StartTime { get; init; }
+ public TimeOnly EndTime { get; init; }
+ public DayOfWeek? DayOfWeek { get; init; }
+}
+
+///
+/// Request to add an on-call override.
+///
+public sealed record OnCallOverrideRequest
+{
+ public string? UserId { get; init; }
+ public DateTimeOffset StartsAt { get; init; }
+ public DateTimeOffset EndsAt { get; init; }
+ public string? Reason { get; init; }
+}
+
+///
+/// Request to resolve who is on-call.
+///
+public sealed record OnCallResolveRequest
+{
+ public DateTimeOffset? EvaluationTime { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/IncidentContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/IncidentContracts.cs
new file mode 100644
index 000000000..f1b57da65
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/IncidentContracts.cs
@@ -0,0 +1,121 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Incident list query parameters.
+///
+public sealed record IncidentListQuery
+{
+ ///
+ /// Filter by status (open, acknowledged, resolved).
+ ///
+ public string? Status { get; init; }
+
+ ///
+ /// Filter by event kind prefix.
+ ///
+ public string? EventKindPrefix { get; init; }
+
+ ///
+ /// Filter incidents after this timestamp.
+ ///
+ public DateTimeOffset? Since { get; init; }
+
+ ///
+ /// Filter incidents before this timestamp.
+ ///
+ public DateTimeOffset? Until { get; init; }
+
+ ///
+ /// Maximum number of results.
+ ///
+ public int? Limit { get; init; }
+
+ ///
+ /// Cursor for pagination.
+ ///
+ public string? Cursor { get; init; }
+}
+
+///
+/// Incident response DTO.
+///
+public sealed record IncidentResponse
+{
+ public required string IncidentId { get; init; }
+ public required string TenantId { get; init; }
+ public required string EventKind { get; init; }
+ public required string Status { get; init; }
+ public required string Severity { get; init; }
+ public required string Title { get; init; }
+ public string? Description { get; init; }
+ public required int EventCount { get; init; }
+ public required DateTimeOffset FirstOccurrence { get; init; }
+ public required DateTimeOffset LastOccurrence { get; init; }
+ public string? AcknowledgedBy { get; init; }
+ public DateTimeOffset? AcknowledgedAt { get; init; }
+ public string? ResolvedBy { get; init; }
+ public DateTimeOffset? ResolvedAt { get; init; }
+ public List? Labels { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Incident list response with pagination.
+///
+public sealed record IncidentListResponse
+{
+ public required List Incidents { get; init; }
+ public required int TotalCount { get; init; }
+ public string? NextCursor { get; init; }
+}
+
+///
+/// Request to acknowledge an incident.
+///
+public sealed record IncidentAckRequest
+{
+ ///
+ /// Actor performing the acknowledgement.
+ ///
+ public string? Actor { get; init; }
+
+ ///
+ /// Optional comment.
+ ///
+ public string? Comment { get; init; }
+}
+
+///
+/// Request to resolve an incident.
+///
+public sealed record IncidentResolveRequest
+{
+ ///
+ /// Actor resolving the incident.
+ ///
+ public string? Actor { get; init; }
+
+ ///
+ /// Resolution reason.
+ ///
+ public string? Reason { get; init; }
+
+ ///
+ /// Optional comment.
+ ///
+ public string? Comment { get; init; }
+}
+
+///
+/// Delivery history item for an incident.
+///
+public sealed record DeliveryHistoryItem
+{
+ public required string DeliveryId { get; init; }
+ public required string ChannelType { get; init; }
+ public required string ChannelName { get; init; }
+ public required string Status { get; init; }
+ public required DateTimeOffset Timestamp { get; init; }
+ public string? ErrorMessage { get; init; }
+ public int Attempts { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/LocalizationContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/LocalizationContracts.cs
new file mode 100644
index 000000000..ab0ce878b
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/LocalizationContracts.cs
@@ -0,0 +1,45 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to create/update a localization bundle.
+///
+public sealed record LocalizationBundleUpsertRequest
+{
+ public string? Locale { get; init; }
+ public string? BundleKey { get; init; }
+ public IReadOnlyDictionary? Strings { get; init; }
+ public bool? IsDefault { get; init; }
+ public string? ParentLocale { get; init; }
+ public string? Description { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
+
+///
+/// Request to resolve localized strings.
+///
+public sealed record LocalizationResolveRequest
+{
+ public string? BundleKey { get; init; }
+ public IReadOnlyList? StringKeys { get; init; }
+ public string? Locale { get; init; }
+}
+
+///
+/// Response containing resolved localized strings.
+///
+public sealed record LocalizationResolveResponse
+{
+ public required IReadOnlyDictionary Strings { get; init; }
+ public required string RequestedLocale { get; init; }
+ public required IReadOnlyList FallbackChain { get; init; }
+}
+
+///
+/// Result for a single localized string.
+///
+public sealed record LocalizedStringResult
+{
+ public required string Value { get; init; }
+ public required string ResolvedLocale { get; init; }
+ public required bool UsedFallback { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalAckRequest.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalAckRequest.cs
new file mode 100644
index 000000000..5c3c2e2ba
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalAckRequest.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request payload for acknowledging a pack approval decision.
+///
+public sealed class PackApprovalAckRequest
+{
+ ///
+ /// Acknowledgement token from the notification.
+ ///
+ [Required]
+ [JsonPropertyName("ackToken")]
+ public string AckToken { get; init; } = string.Empty;
+
+ ///
+ /// Approval decision: "approved" or "rejected".
+ ///
+ [JsonPropertyName("decision")]
+ public string? Decision { get; init; }
+
+ ///
+ /// Optional comment for audit trail.
+ ///
+ [JsonPropertyName("comment")]
+ public string? Comment { get; init; }
+
+ ///
+ /// Identity acknowledging the approval.
+ ///
+ [JsonPropertyName("actor")]
+ public string? Actor { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalRequest.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalRequest.cs
new file mode 100644
index 000000000..599ad3b27
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/PackApprovalRequest.cs
@@ -0,0 +1,88 @@
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request payload for pack approval events from Task Runner.
+/// See: docs/notifications/pack-approvals-contract.md
+///
+public sealed class PackApprovalRequest
+{
+ ///
+ /// Unique event identifier for deduplication.
+ ///
+ [JsonPropertyName("eventId")]
+ public Guid EventId { get; init; }
+
+ ///
+ /// Event timestamp in UTC (ISO 8601).
+ ///
+ [JsonPropertyName("issuedAt")]
+ public DateTimeOffset IssuedAt { get; init; }
+
+ ///
+ /// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
+ ///
+ [JsonPropertyName("kind")]
+ public string Kind { get; init; } = string.Empty;
+
+ ///
+ /// Package identifier in PURL format.
+ ///
+ [JsonPropertyName("packId")]
+ public string PackId { get; init; } = string.Empty;
+
+ ///
+ /// Policy metadata (id and version).
+ ///
+ [JsonPropertyName("policy")]
+ public PackApprovalPolicy? Policy { get; init; }
+
+ ///
+ /// Current approval state: pending, approved, rejected, hold, expired.
+ ///
+ [JsonPropertyName("decision")]
+ public string Decision { get; init; } = string.Empty;
+
+ ///
+ /// Identity that triggered the event.
+ ///
+ [JsonPropertyName("actor")]
+ public string Actor { get; init; } = string.Empty;
+
+ ///
+ /// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
+ ///
+ [JsonPropertyName("resumeToken")]
+ public string? ResumeToken { get; init; }
+
+ ///
+ /// Human-readable summary for notifications.
+ ///
+ [JsonPropertyName("summary")]
+ public string? Summary { get; init; }
+
+ ///
+ /// Custom key-value metadata labels.
+ ///
+ [JsonPropertyName("labels")]
+ public Dictionary? Labels { get; init; }
+}
+
+///
+/// Policy metadata associated with a pack approval.
+///
+public sealed class PackApprovalPolicy
+{
+ ///
+ /// Policy identifier.
+ ///
+ [JsonPropertyName("id")]
+ public string? Id { get; init; }
+
+ ///
+ /// Policy version.
+ ///
+ [JsonPropertyName("version")]
+ public string? Version { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/QuietHoursContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/QuietHoursContracts.cs
new file mode 100644
index 000000000..9d84fa49d
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/QuietHoursContracts.cs
@@ -0,0 +1,60 @@
+using System.Collections.Immutable;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to create or update a quiet hours schedule.
+///
+public sealed class QuietHoursUpsertRequest
+{
+ public required string Name { get; init; }
+ public required string CronExpression { get; init; }
+ public required TimeSpan Duration { get; init; }
+ public required string TimeZone { get; init; }
+ public string? ChannelId { get; init; }
+ public bool? Enabled { get; init; }
+ public string? Description { get; init; }
+ public ImmutableDictionary? Metadata { get; init; }
+}
+
+///
+/// Request to create or update a maintenance window.
+///
+public sealed class MaintenanceWindowUpsertRequest
+{
+ public required string Name { get; init; }
+ public required DateTimeOffset StartsAt { get; init; }
+ public required DateTimeOffset EndsAt { get; init; }
+ public bool? SuppressNotifications { get; init; }
+ public string? Reason { get; init; }
+ public ImmutableArray ChannelIds { get; init; } = [];
+ public ImmutableArray RuleIds { get; init; } = [];
+ public ImmutableDictionary? Metadata { get; init; }
+}
+
+///
+/// Request to create or update a throttle configuration.
+///
+public sealed class ThrottleConfigUpsertRequest
+{
+ public required string Name { get; init; }
+ public required TimeSpan DefaultWindow { get; init; }
+ public int? MaxNotificationsPerWindow { get; init; }
+ public string? ChannelId { get; init; }
+ public bool? IsDefault { get; init; }
+ public bool? Enabled { get; init; }
+ public string? Description { get; init; }
+ public ImmutableDictionary? Metadata { get; init; }
+}
+
+///
+/// Request to create an operator override.
+///
+public sealed class OperatorOverrideCreateRequest
+{
+ public required string OverrideType { get; init; }
+ public required DateTimeOffset ExpiresAt { get; init; }
+ public string? ChannelId { get; init; }
+ public string? RuleId { get; init; }
+ public string? Reason { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/RetentionContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/RetentionContracts.cs
new file mode 100644
index 000000000..5adcf7f9e
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/RetentionContracts.cs
@@ -0,0 +1,143 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Retention policy configuration request/response.
+///
+public sealed record RetentionPolicyDto
+{
+ ///
+ /// Retention period for delivery records in days.
+ ///
+ public int DeliveryRetentionDays { get; init; } = 90;
+
+ ///
+ /// Retention period for audit log entries in days.
+ ///
+ public int AuditRetentionDays { get; init; } = 365;
+
+ ///
+ /// Retention period for dead-letter entries in days.
+ ///
+ public int DeadLetterRetentionDays { get; init; } = 30;
+
+ ///
+ /// Retention period for storm tracking data in days.
+ ///
+ public int StormDataRetentionDays { get; init; } = 7;
+
+ ///
+ /// Retention period for inbox messages in days.
+ ///
+ public int InboxRetentionDays { get; init; } = 30;
+
+ ///
+ /// Retention period for event history in days.
+ ///
+ public int EventHistoryRetentionDays { get; init; } = 30;
+
+ ///
+ /// Whether automatic cleanup is enabled.
+ ///
+ public bool AutoCleanupEnabled { get; init; } = true;
+
+ ///
+ /// Cron expression for automatic cleanup schedule.
+ ///
+ public string CleanupSchedule { get; init; } = "0 2 * * *";
+
+ ///
+ /// Maximum records to delete per cleanup run.
+ ///
+ public int MaxDeletesPerRun { get; init; } = 10000;
+
+ ///
+ /// Whether to keep resolved/acknowledged deliveries longer.
+ ///
+ public bool ExtendResolvedRetention { get; init; } = true;
+
+ ///
+ /// Extension multiplier for resolved items.
+ ///
+ public double ResolvedRetentionMultiplier { get; init; } = 2.0;
+}
+
+///
+/// Request to update retention policy.
+///
+public sealed record UpdateRetentionPolicyRequest
+{
+ public required RetentionPolicyDto Policy { get; init; }
+}
+
+///
+/// Response for retention policy operations.
+///
+public sealed record RetentionPolicyResponse
+{
+ public required string TenantId { get; init; }
+ public required RetentionPolicyDto Policy { get; init; }
+}
+
+///
+/// Response for retention cleanup execution.
+///
+public sealed record RetentionCleanupResponse
+{
+ public required string TenantId { get; init; }
+ public required bool Success { get; init; }
+ public string? Error { get; init; }
+ public required DateTimeOffset ExecutedAt { get; init; }
+ public required double DurationMs { get; init; }
+ public required RetentionCleanupCountsDto Counts { get; init; }
+}
+
+///
+/// Cleanup counts DTO.
+///
+public sealed record RetentionCleanupCountsDto
+{
+ public int Deliveries { get; init; }
+ public int AuditEntries { get; init; }
+ public int DeadLetterEntries { get; init; }
+ public int StormData { get; init; }
+ public int InboxMessages { get; init; }
+ public int Events { get; init; }
+ public int Total { get; init; }
+}
+
+///
+/// Response for cleanup preview.
+///
+public sealed record RetentionCleanupPreviewResponse
+{
+ public required string TenantId { get; init; }
+ public required DateTimeOffset PreviewedAt { get; init; }
+ public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
+ public required RetentionPolicyDto PolicyApplied { get; init; }
+ public required IReadOnlyDictionary CutoffDates { get; init; }
+}
+
+///
+/// Response for last cleanup execution.
+///
+public sealed record RetentionCleanupExecutionResponse
+{
+ public required string ExecutionId { get; init; }
+ public required string TenantId { get; init; }
+ public required DateTimeOffset StartedAt { get; init; }
+ public DateTimeOffset? CompletedAt { get; init; }
+ public required string Status { get; init; }
+ public RetentionCleanupCountsDto? Counts { get; init; }
+ public string? Error { get; init; }
+}
+
+///
+/// Response for cleanup all tenants.
+///
+public sealed record RetentionCleanupAllResponse
+{
+ public required IReadOnlyList Results { get; init; }
+ public required int SuccessCount { get; init; }
+ public required int FailureCount { get; init; }
+ public required int TotalDeleted { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/RiskEventRequest.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/RiskEventRequest.cs
new file mode 100644
index 000000000..f21036602
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/RiskEventRequest.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+public sealed record RiskEventRequest
+{
+ public Guid EventId { get; init; }
+
+ ///
+ /// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
+ ///
+ public string? Kind { get; init; }
+
+ public string? Actor { get; init; }
+
+ public DateTimeOffset? Timestamp { get; init; }
+
+ public JsonObject? Payload { get; init; }
+
+ public IDictionary? Attributes { get; init; }
+
+ public string? ResumeToken { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/RuleContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/RuleContracts.cs
new file mode 100644
index 000000000..eb39ac100
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/RuleContracts.cs
@@ -0,0 +1,128 @@
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to create or update a notification rule.
+///
+public sealed record RuleCreateRequest
+{
+ public required string RuleId { get; init; }
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public bool Enabled { get; init; } = true;
+ public required RuleMatchRequest Match { get; init; }
+ public required List Actions { get; init; }
+ public Dictionary? Labels { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Request to update an existing rule.
+///
+public sealed record RuleUpdateRequest
+{
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public bool? Enabled { get; init; }
+ public RuleMatchRequest? Match { get; init; }
+ public List? Actions { get; init; }
+ public Dictionary? Labels { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Request to upsert a rule (v2 API).
+///
+public sealed record RuleUpsertRequest
+{
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public bool? Enabled { get; init; }
+ public RuleMatchRequest? Match { get; init; }
+ public List? Actions { get; init; }
+ public Dictionary? Labels { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Rule match criteria.
+///
+public sealed record RuleMatchRequest
+{
+ public List? EventKinds { get; init; }
+ public List? Namespaces { get; init; }
+ public List? Repositories { get; init; }
+ public List? Digests { get; init; }
+ public List? Labels { get; init; }
+ public List? ComponentPurls { get; init; }
+ public string? MinSeverity { get; init; }
+ public List? Verdicts { get; init; }
+ public bool? KevOnly { get; init; }
+}
+
+///
+/// Rule action configuration.
+///
+public sealed record RuleActionRequest
+{
+ public required string ActionId { get; init; }
+ public required string Channel { get; init; }
+ public string? Template { get; init; }
+ public string? Digest { get; init; }
+ public string? Throttle { get; init; } // ISO 8601 duration
+ public string? Locale { get; init; }
+ public bool Enabled { get; init; } = true;
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Rule response DTO.
+///
+public sealed record RuleResponse
+{
+ public required string RuleId { get; init; }
+ public required string TenantId { get; init; }
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public required bool Enabled { get; init; }
+ public required RuleMatchResponse Match { get; init; }
+ public required List Actions { get; init; }
+ public Dictionary? Labels { get; init; }
+ public Dictionary? Metadata { get; init; }
+ public string? CreatedBy { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public string? UpdatedBy { get; init; }
+ public DateTimeOffset UpdatedAt { get; init; }
+}
+
+///
+/// Rule match response.
+///
+public sealed record RuleMatchResponse
+{
+ public List EventKinds { get; init; } = [];
+ public List Namespaces { get; init; } = [];
+ public List Repositories { get; init; } = [];
+ public List Digests { get; init; } = [];
+ public List Labels { get; init; } = [];
+ public List ComponentPurls { get; init; } = [];
+ public string? MinSeverity { get; init; }
+ public List Verdicts { get; init; } = [];
+ public bool KevOnly { get; init; }
+}
+
+///
+/// Rule action response.
+///
+public sealed record RuleActionResponse
+{
+ public required string ActionId { get; init; }
+ public required string Channel { get; init; }
+ public string? Template { get; init; }
+ public string? Digest { get; init; }
+ public string? Throttle { get; init; }
+ public string? Locale { get; init; }
+ public required bool Enabled { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/SecurityContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/SecurityContracts.cs
new file mode 100644
index 000000000..f834800b8
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/SecurityContracts.cs
@@ -0,0 +1,305 @@
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to acknowledge a notification via signed token.
+///
+public sealed record AckRequest
+{
+ ///
+ /// Optional comment for the acknowledgement.
+ ///
+ public string? Comment { get; init; }
+
+ ///
+ /// Optional metadata to include with the acknowledgement.
+ ///
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
+
+///
+/// Response from acknowledging a notification.
+///
+public sealed record AckResponse
+{
+ ///
+ /// Whether the acknowledgement was successful.
+ ///
+ public required bool Success { get; init; }
+
+ ///
+ /// The delivery ID that was acknowledged.
+ ///
+ public string? DeliveryId { get; init; }
+
+ ///
+ /// The action that was performed.
+ ///
+ public string? Action { get; init; }
+
+ ///
+ /// When the acknowledgement was processed.
+ ///
+ public DateTimeOffset? ProcessedAt { get; init; }
+
+ ///
+ /// Error message if unsuccessful.
+ ///
+ public string? Error { get; init; }
+}
+
+///
+/// Request to create an acknowledgement token.
+///
+public sealed record CreateAckTokenRequest
+{
+ ///
+ /// The delivery ID to create an ack token for.
+ ///
+ public string? DeliveryId { get; init; }
+
+ ///
+ /// The action to acknowledge (e.g., "ack", "resolve", "escalate").
+ ///
+ public string? Action { get; init; }
+
+ ///
+ /// Optional expiration in hours. Default: 168 (7 days).
+ ///
+ public int? ExpirationHours { get; init; }
+
+ ///
+ /// Optional metadata to embed in the token.
+ ///
+ public IReadOnlyDictionary? Metadata { get; init; }
+}
+
+///
+/// Response containing the created ack token.
+///
+public sealed record CreateAckTokenResponse
+{
+ ///
+ /// The signed token string.
+ ///
+ public required string Token { get; init; }
+
+ ///
+ /// The full acknowledgement URL.
+ ///
+ public required string AckUrl { get; init; }
+
+ ///
+ /// When the token expires.
+ ///
+ public required DateTimeOffset ExpiresAt { get; init; }
+}
+
+///
+/// Request to verify an ack token.
+///
+public sealed record VerifyAckTokenRequest
+{
+ ///
+ /// The token to verify.
+ ///
+ public string? Token { get; init; }
+}
+
+///
+/// Response from token verification.
+///
+public sealed record VerifyAckTokenResponse
+{
+ ///
+ /// Whether the token is valid.
+ ///
+ public required bool IsValid { get; init; }
+
+ ///
+ /// The delivery ID embedded in the token.
+ ///
+ public string? DeliveryId { get; init; }
+
+ ///
+ /// The action embedded in the token.
+ ///
+ public string? Action { get; init; }
+
+ ///
+ /// When the token expires.
+ ///
+ public DateTimeOffset? ExpiresAt { get; init; }
+
+ ///
+ /// Failure reason if invalid.
+ ///
+ public string? FailureReason { get; init; }
+}
+
+///
+/// Request to validate HTML content.
+///
+public sealed record ValidateHtmlRequest
+{
+ ///
+ /// The HTML content to validate.
+ ///
+ public string? Html { get; init; }
+}
+
+///
+/// Response from HTML validation.
+///
+public sealed record ValidateHtmlResponse
+{
+ ///
+ /// Whether the HTML is safe.
+ ///
+ public required bool IsSafe { get; init; }
+
+ ///
+ /// List of security issues found.
+ ///
+ public required IReadOnlyList Issues { get; init; }
+
+ ///
+ /// Statistics about the HTML content.
+ ///
+ public HtmlStats? Stats { get; init; }
+}
+
+///
+/// An HTML security issue.
+///
+public sealed record HtmlIssue
+{
+ ///
+ /// The type of issue.
+ ///
+ public required string Type { get; init; }
+
+ ///
+ /// Description of the issue.
+ ///
+ public required string Description { get; init; }
+
+ ///
+ /// The element name if applicable.
+ ///
+ public string? Element { get; init; }
+
+ ///
+ /// The attribute name if applicable.
+ ///
+ public string? Attribute { get; init; }
+}
+
+///
+/// HTML content statistics.
+///
+public sealed record HtmlStats
+{
+ ///
+ /// Total character count.
+ ///
+ public int CharacterCount { get; init; }
+
+ ///
+ /// Number of HTML elements.
+ ///
+ public int ElementCount { get; init; }
+
+ ///
+ /// Maximum nesting depth.
+ ///
+ public int MaxDepth { get; init; }
+
+ ///
+ /// Number of links.
+ ///
+ public int LinkCount { get; init; }
+
+ ///
+ /// Number of images.
+ ///
+ public int ImageCount { get; init; }
+}
+
+///
+/// Request to sanitize HTML content.
+///
+public sealed record SanitizeHtmlRequest
+{
+ ///
+ /// The HTML content to sanitize.
+ ///
+ public string? Html { get; init; }
+
+ ///
+ /// Whether to allow data: URLs. Default: false.
+ ///
+ public bool AllowDataUrls { get; init; }
+
+ ///
+ /// Additional tags to allow.
+ ///
+ public IReadOnlyList? AdditionalAllowedTags { get; init; }
+}
+
+///
+/// Response containing sanitized HTML.
+///
+public sealed record SanitizeHtmlResponse
+{
+ ///
+ /// The sanitized HTML content.
+ ///
+ public required string SanitizedHtml { get; init; }
+
+ ///
+ /// Whether any changes were made.
+ ///
+ public required bool WasModified { get; init; }
+}
+
+///
+/// Request to rotate a webhook secret.
+///
+public sealed record RotateWebhookSecretRequest
+{
+ ///
+ /// The channel ID to rotate the secret for.
+ ///
+ public string? ChannelId { get; init; }
+}
+
+///
+/// Response from webhook secret rotation.
+///
+public sealed record RotateWebhookSecretResponse
+{
+ ///
+ /// Whether rotation succeeded.
+ ///
+ public required bool Success { get; init; }
+
+ ///
+ /// The new secret (only shown once).
+ ///
+ public string? NewSecret { get; init; }
+
+ ///
+ /// When the new secret becomes active.
+ ///
+ public DateTimeOffset? ActiveAt { get; init; }
+
+ ///
+ /// When the old secret expires.
+ ///
+ public DateTimeOffset? OldSecretExpiresAt { get; init; }
+
+ ///
+ /// Error message if unsuccessful.
+ ///
+ public string? Error { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/SimulationContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/SimulationContracts.cs
new file mode 100644
index 000000000..6a150593d
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/SimulationContracts.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+using System.Text.Json.Nodes;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to run a historical simulation against past events.
+///
+public sealed class SimulationRunRequest
+{
+ public required DateTimeOffset PeriodStart { get; init; }
+ public required DateTimeOffset PeriodEnd { get; init; }
+ public ImmutableArray RuleIds { get; init; } = [];
+ public ImmutableArray EventKinds { get; init; } = [];
+ public int MaxEvents { get; init; } = 1000;
+ public bool IncludeNonMatches { get; init; } = true;
+ public bool EvaluateThrottling { get; init; } = true;
+ public bool EvaluateQuietHours { get; init; } = true;
+ public DateTimeOffset? EvaluationTimestamp { get; init; }
+}
+
+///
+/// Request to simulate a single event against current rules.
+///
+public sealed class SimulateSingleEventRequest
+{
+ public required JsonObject EventPayload { get; init; }
+ public ImmutableArray RuleIds { get; init; } = [];
+ public DateTimeOffset? EvaluationTimestamp { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Contracts/TemplateContracts.cs b/src/Notify/StellaOps.Notify.WebService/Contracts/TemplateContracts.cs
new file mode 100644
index 000000000..8a94115a3
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Contracts/TemplateContracts.cs
@@ -0,0 +1,150 @@
+
+using StellaOps.Notify.Models;
+using System.Text.Json.Nodes;
+
+namespace StellaOps.Notify.WebService.Contracts;
+
+///
+/// Request to preview a template rendering.
+///
+public sealed record TemplatePreviewRequest
+{
+ ///
+ /// Template ID to preview (mutually exclusive with TemplateBody).
+ ///
+ public string? TemplateId { get; init; }
+
+ ///
+ /// Raw template body to preview (mutually exclusive with TemplateId).
+ ///
+ public string? TemplateBody { get; init; }
+
+ ///
+ /// Sample event payload for rendering.
+ ///
+ public JsonObject? SamplePayload { get; init; }
+
+ ///
+ /// Event kind for context.
+ ///
+ public string? EventKind { get; init; }
+
+ ///
+ /// Sample attributes.
+ ///
+ public Dictionary? SampleAttributes { get; init; }
+
+ ///
+ /// Output format override.
+ ///
+ public string? OutputFormat { get; init; }
+
+ ///
+ /// Whether to include provenance links in preview output.
+ ///
+ public bool? IncludeProvenance { get; init; }
+
+ ///
+ /// Base URL for provenance links.
+ ///
+ public string? ProvenanceBaseUrl { get; init; }
+
+ ///
+ /// Optional format override for rendering.
+ ///
+ public NotifyDeliveryFormat? FormatOverride { get; init; }
+}
+
+///
+/// Response from template preview.
+///
+public sealed record TemplatePreviewResponse
+{
+ ///
+ /// Rendered body content.
+ ///
+ public required string RenderedBody { get; init; }
+
+ ///
+ /// Rendered subject (if applicable).
+ ///
+ public string? RenderedSubject { get; init; }
+
+ ///
+ /// Content hash for deduplication.
+ ///
+ public required string BodyHash { get; init; }
+
+ ///
+ /// Output format used.
+ ///
+ public required string Format { get; init; }
+
+ ///
+ /// Validation warnings (if any).
+ ///
+ public List? Warnings { get; init; }
+}
+
+///
+/// Request to create or update a template.
+///
+public sealed record TemplateCreateRequest
+{
+ public required string TemplateId { get; init; }
+ public required string Key { get; init; }
+ public required string ChannelType { get; init; }
+ public required string Locale { get; init; }
+ public required string Body { get; init; }
+ public string? RenderMode { get; init; }
+ public string? Format { get; init; }
+ public string? Description { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Request to upsert a template (v2 API).
+///
+public sealed record TemplateUpsertRequest
+{
+ public required string Key { get; init; }
+ public NotifyChannelType? ChannelType { get; init; }
+ public string? Locale { get; init; }
+ public required string Body { get; init; }
+ public NotifyTemplateRenderMode? RenderMode { get; init; }
+ public NotifyDeliveryFormat? Format { get; init; }
+ public string? Description { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Template response DTO.
+///
+public sealed record TemplateResponse
+{
+ public required string TemplateId { get; init; }
+ public required string TenantId { get; init; }
+ public required string Key { get; init; }
+ public required string ChannelType { get; init; }
+ public required string Locale { get; init; }
+ public required string Body { get; init; }
+ public required string RenderMode { get; init; }
+ public required string Format { get; init; }
+ public string? Description { get; init; }
+ public Dictionary? Metadata { get; init; }
+ public string? CreatedBy { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public string? UpdatedBy { get; init; }
+ public DateTimeOffset UpdatedAt { get; init; }
+}
+
+///
+/// Template list query parameters.
+///
+public sealed record TemplateListQuery
+{
+ public string? KeyPrefix { get; init; }
+ public string? ChannelType { get; init; }
+ public string? Locale { get; init; }
+ public int? Limit { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/EscalationEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/EscalationEndpoints.cs
new file mode 100644
index 000000000..67b522a80
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/EscalationEndpoints.cs
@@ -0,0 +1,844 @@
+
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Escalation;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// API endpoints for escalation management.
+///
+public static class EscalationEndpoints
+{
+ ///
+ /// Maps escalation endpoints.
+ ///
+ public static IEndpointRouteBuilder MapEscalationEndpoints(this IEndpointRouteBuilder app)
+ {
+ // Escalation Policies
+ var policies = app.MapGroup("/api/v2/escalation-policies")
+ .WithTags("Escalation Policies")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ policies.MapGet("/", ListPoliciesAsync)
+ .WithName("ListEscalationPolicies")
+ .WithSummary("List escalation policies")
+ .WithDescription(_t("notifier.escalation_policy.list_description"));
+
+ policies.MapGet("/{policyId}", GetPolicyAsync)
+ .WithName("GetEscalationPolicy")
+ .WithSummary("Get an escalation policy")
+ .WithDescription(_t("notifier.escalation_policy.get_description"));
+
+ policies.MapPost("/", CreatePolicyAsync)
+ .WithName("CreateEscalationPolicy")
+ .WithSummary("Create an escalation policy")
+ .WithDescription(_t("notifier.escalation_policy.create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ policies.MapPut("/{policyId}", UpdatePolicyAsync)
+ .WithName("UpdateEscalationPolicy")
+ .WithSummary("Update an escalation policy")
+ .WithDescription(_t("notifier.escalation_policy.update_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ policies.MapDelete("/{policyId}", DeletePolicyAsync)
+ .WithName("DeleteEscalationPolicy")
+ .WithSummary("Delete an escalation policy")
+ .WithDescription(_t("notifier.escalation_policy.delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ // On-Call Schedules
+ var schedules = app.MapGroup("/api/v2/oncall-schedules")
+ .WithTags("On-Call Schedules")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ schedules.MapGet("/", ListSchedulesAsync)
+ .WithName("ListOnCallSchedules")
+ .WithSummary("List on-call schedules")
+ .WithDescription(_t("notifier.oncall_schedule.list_description"));
+
+ schedules.MapGet("/{scheduleId}", GetScheduleAsync)
+ .WithName("GetOnCallSchedule")
+ .WithSummary("Get an on-call schedule")
+ .WithDescription(_t("notifier.oncall_schedule.get_description"));
+
+ schedules.MapPost("/", CreateScheduleAsync)
+ .WithName("CreateOnCallSchedule")
+ .WithSummary("Create an on-call schedule")
+ .WithDescription(_t("notifier.oncall_schedule.create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
+ .WithName("UpdateOnCallSchedule")
+ .WithSummary("Update an on-call schedule")
+ .WithDescription(_t("notifier.oncall_schedule.update_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
+ .WithName("DeleteOnCallSchedule")
+ .WithSummary("Delete an on-call schedule")
+ .WithDescription(_t("notifier.oncall_schedule.delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
+ .WithName("GetCurrentOnCall")
+ .WithSummary("Get current on-call users")
+ .WithDescription(_t("notifier.oncall_schedule.current_description"));
+
+ schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
+ .WithName("CreateOnCallOverride")
+ .WithSummary("Create an on-call override")
+ .WithDescription(_t("notifier.oncall_schedule.create_override_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
+ .WithName("DeleteOnCallOverride")
+ .WithSummary("Delete an on-call override")
+ .WithDescription(_t("notifier.oncall_schedule.delete_override_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ // Active Escalations
+ var escalations = app.MapGroup("/api/v2/escalations")
+ .WithTags("Escalations")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ escalations.MapGet("/", ListActiveEscalationsAsync)
+ .WithName("ListActiveEscalations")
+ .WithSummary("List active escalations")
+ .WithDescription(_t("notifier.escalation.list_description"));
+
+ escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
+ .WithName("GetEscalationState")
+ .WithSummary("Get escalation state for an incident")
+ .WithDescription(_t("notifier.escalation.get_description"));
+
+ escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
+ .WithName("StartEscalation")
+ .WithSummary("Start escalation for an incident")
+ .WithDescription(_t("notifier.escalation.start_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
+ .WithName("ManualEscalate")
+ .WithSummary("Manually escalate to next level")
+ .WithDescription(_t("notifier.escalation.manual_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
+ .WithName("StopEscalation")
+ .WithSummary("Stop escalation")
+ .WithDescription(_t("notifier.escalation.stop_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyEscalate);
+
+ // Ack Bridge
+ var ack = app.MapGroup("/api/v2/ack")
+ .WithTags("Acknowledgment")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyOperator)
+ .RequireTenant();
+
+ ack.MapPost("/", ProcessAckAsync)
+ .WithName("ProcessAck")
+ .WithSummary("Process an acknowledgment")
+ .WithDescription(_t("notifier.ack.process_description"));
+
+ ack.MapGet("/", ProcessAckLinkAsync)
+ .WithName("ProcessAckLink")
+ .WithSummary("Process an acknowledgment link")
+ .WithDescription(_t("notifier.ack.link_description"));
+
+ ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
+ .WithName("PagerDutyWebhook")
+ .WithSummary("Process PagerDuty webhook")
+ .WithDescription(_t("notifier.ack.pagerduty_description"))
+ .AllowAnonymous();
+
+ ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
+ .WithName("OpsGenieWebhook")
+ .WithSummary("Process OpsGenie webhook")
+ .WithDescription(_t("notifier.ack.opsgenie_description"))
+ .AllowAnonymous();
+
+ return app;
+ }
+
+ #region Policy Endpoints
+
+ private static async Task ListPoliciesAsync(
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IEscalationPolicyService policyService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var policies = await policyService.ListPoliciesAsync(tenantId, cancellationToken);
+ return Results.Ok(policies);
+ }
+
+ private static async Task GetPolicyAsync(
+ string policyId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IEscalationPolicyService policyService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var policy = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
+ return policy is null
+ ? Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) })
+ : Results.Ok(policy);
+ }
+
+ private static async Task CreatePolicyAsync(
+ [FromBody] EscalationPolicyApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IEscalationPolicyService policyService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.policy_name_required") });
+ }
+
+ if (request.Levels is null || request.Levels.Count == 0)
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.policy_levels_required") });
+ }
+
+ var policy = MapToPolicy(request, tenantId);
+ var created = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
+
+ return Results.Created($"/api/v2/escalation-policies/{created.PolicyId}", created);
+ }
+
+ private static async Task UpdatePolicyAsync(
+ string policyId,
+ [FromBody] EscalationPolicyApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IEscalationPolicyService policyService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
+ }
+
+ var existing = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
+ if (existing is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
+ }
+
+ var policy = MapToPolicy(request, tenantId) with
+ {
+ PolicyId = policyId,
+ CreatedAt = existing.CreatedAt,
+ CreatedBy = existing.CreatedBy
+ };
+
+ var updated = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
+ return Results.Ok(updated);
+ }
+
+ private static async Task DeletePolicyAsync(
+ string policyId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IEscalationPolicyService policyService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var deleted = await policyService.DeletePolicyAsync(tenantId, policyId, actor, cancellationToken);
+ return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
+ }
+
+ #endregion
+
+ #region Schedule Endpoints
+
+ private static async Task ListSchedulesAsync(
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var schedules = await scheduleService.ListSchedulesAsync(tenantId, cancellationToken);
+ return Results.Ok(schedules);
+ }
+
+ private static async Task GetScheduleAsync(
+ string scheduleId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var schedule = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
+ return schedule is null
+ ? Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) })
+ : Results.Ok(schedule);
+ }
+
+ private static async Task CreateScheduleAsync(
+ [FromBody] OnCallScheduleApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.schedule_name_required") });
+ }
+
+ var schedule = MapToSchedule(request, tenantId);
+ var created = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
+
+ return Results.Created($"/api/v2/oncall-schedules/{created.ScheduleId}", created);
+ }
+
+ private static async Task UpdateScheduleAsync(
+ string scheduleId,
+ [FromBody] OnCallScheduleApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
+ }
+
+ var existing = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
+ if (existing is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
+ }
+
+ var schedule = MapToSchedule(request, tenantId) with
+ {
+ ScheduleId = scheduleId,
+ CreatedAt = existing.CreatedAt,
+ CreatedBy = existing.CreatedBy
+ };
+
+ var updated = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
+ return Results.Ok(updated);
+ }
+
+ private static async Task DeleteScheduleAsync(
+ string scheduleId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var deleted = await scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor, cancellationToken);
+ return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
+ }
+
+ private static async Task GetCurrentOnCallAsync(
+ string scheduleId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromQuery] DateTimeOffset? atTime,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var users = await scheduleService.GetCurrentOnCallAsync(tenantId, scheduleId, atTime, cancellationToken);
+ return Results.Ok(users);
+ }
+
+ private static async Task CreateOverrideAsync(
+ string scheduleId,
+ [FromBody] OnCallOverrideApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var @override = new OnCallOverride
+ {
+ OverrideId = request.OverrideId ?? Guid.NewGuid().ToString("N")[..16],
+ User = new OnCallUser
+ {
+ UserId = request.UserId ?? "",
+ Name = request.UserName ?? request.UserId ?? ""
+ },
+ StartsAt = request.StartsAt ?? DateTimeOffset.UtcNow,
+ EndsAt = request.EndsAt ?? DateTimeOffset.UtcNow.AddHours(8),
+ Reason = request.Reason
+ };
+
+ try
+ {
+ var created = await scheduleService.CreateOverrideAsync(tenantId, scheduleId, @override, actor, cancellationToken);
+ return Results.Created($"/api/v2/oncall-schedules/{scheduleId}/overrides/{created.OverrideId}", created);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.NotFound(new { error = ex.Message });
+ }
+ }
+
+ private static async Task DeleteOverrideAsync(
+ string scheduleId,
+ string overrideId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IOnCallScheduleService scheduleService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var deleted = await scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor, cancellationToken);
+ return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
+ }
+
+ #endregion
+
+ #region Escalation Endpoints
+
+ private static async Task ListActiveEscalationsAsync(
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IEscalationEngine escalationEngine,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var escalations = await escalationEngine.ListActiveEscalationsAsync(tenantId, cancellationToken);
+ return Results.Ok(escalations);
+ }
+
+ private static async Task GetEscalationStateAsync(
+ string incidentId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IEscalationEngine escalationEngine,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var state = await escalationEngine.GetEscalationStateAsync(tenantId, incidentId, cancellationToken);
+ return state is null
+ ? Results.NotFound(new { error = _t("notifier.error.escalation_not_found", incidentId) })
+ : Results.Ok(state);
+ }
+
+ private static async Task StartEscalationAsync(
+ string incidentId,
+ [FromBody] StartEscalationApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IEscalationEngine escalationEngine,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.PolicyId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.policy_id_required") });
+ }
+
+ try
+ {
+ var state = await escalationEngine.StartEscalationAsync(tenantId, incidentId, request.PolicyId, cancellationToken);
+ return Results.Created($"/api/v2/escalations/{incidentId}", state);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ }
+
+ private static async Task ManualEscalateAsync(
+ string incidentId,
+ [FromBody] ManualEscalateApiRequest? request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IEscalationEngine escalationEngine,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var state = await escalationEngine.EscalateAsync(tenantId, incidentId, request?.Reason, actor, cancellationToken);
+ return state is null
+ ? Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) })
+ : Results.Ok(state);
+ }
+
+ private static async Task StopEscalationAsync(
+ string incidentId,
+ [FromBody] StopEscalationApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IEscalationEngine escalationEngine,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var stopped = await escalationEngine.StopEscalationAsync(
+ tenantId, incidentId, request.Reason ?? "Manually stopped", actor, cancellationToken);
+
+ return stopped
+ ? Results.NoContent()
+ : Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) });
+ }
+
+ #endregion
+
+ #region Ack Endpoints
+
+ private static async Task ProcessAckAsync(
+ [FromBody] AckApiRequest request,
+ [FromServices] IAckBridge ackBridge,
+ CancellationToken cancellationToken)
+ {
+ var bridgeRequest = new AckBridgeRequest
+ {
+ Source = AckSource.Api,
+ TenantId = request.TenantId,
+ IncidentId = request.IncidentId,
+ AcknowledgedBy = request.AcknowledgedBy ?? "api",
+ Comment = request.Comment
+ };
+
+ var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
+ return result.Success
+ ? Results.Ok(result)
+ : Results.BadRequest(new { error = result.Error });
+ }
+
+ private static async Task ProcessAckLinkAsync(
+ [FromQuery] string token,
+ [FromServices] IAckBridge ackBridge,
+ CancellationToken cancellationToken)
+ {
+ var validation = await ackBridge.ValidateTokenAsync(token, cancellationToken);
+ if (!validation.IsValid)
+ {
+ return Results.BadRequest(new { error = validation.Error });
+ }
+
+ var bridgeRequest = new AckBridgeRequest
+ {
+ Source = AckSource.SignedLink,
+ Token = token,
+ AcknowledgedBy = validation.TargetId ?? "link"
+ };
+
+ var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
+ return result.Success
+ ? Results.Ok(new { message = "Acknowledged successfully", incidentId = result.IncidentId })
+ : Results.BadRequest(new { error = result.Error });
+ }
+
+ private static async Task ProcessPagerDutyWebhookAsync(
+ HttpContext context,
+ [FromServices] IEnumerable adapters,
+ [FromServices] IAckBridge ackBridge,
+ CancellationToken cancellationToken)
+ {
+ var pagerDutyAdapter = adapters.OfType().FirstOrDefault();
+ if (pagerDutyAdapter is null)
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.pagerduty_not_configured") });
+ }
+
+ using var reader = new StreamReader(context.Request.Body);
+ var payload = await reader.ReadToEndAsync(cancellationToken);
+
+ var request = pagerDutyAdapter.ParseWebhook(payload);
+ if (request is null)
+ {
+ return Results.Ok(new { message = "Webhook received but no action taken." });
+ }
+
+ var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
+ return Results.Ok(new { processed = result.Success });
+ }
+
+ private static async Task ProcessOpsGenieWebhookAsync(
+ HttpContext context,
+ [FromServices] IEnumerable adapters,
+ [FromServices] IAckBridge ackBridge,
+ CancellationToken cancellationToken)
+ {
+ var opsGenieAdapter = adapters.OfType().FirstOrDefault();
+ if (opsGenieAdapter is null)
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.opsgenie_not_configured") });
+ }
+
+ using var reader = new StreamReader(context.Request.Body);
+ var payload = await reader.ReadToEndAsync(cancellationToken);
+
+ var request = opsGenieAdapter.ParseWebhook(payload);
+ if (request is null)
+ {
+ return Results.Ok(new { message = "Webhook received but no action taken." });
+ }
+
+ var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
+ return Results.Ok(new { processed = result.Success });
+ }
+
+ #endregion
+
+ #region Mapping
+
+ private static EscalationPolicy MapToPolicy(EscalationPolicyApiRequest request, string tenantId) => new()
+ {
+ PolicyId = request.PolicyId ?? Guid.NewGuid().ToString("N")[..16],
+ TenantId = tenantId,
+ Name = request.Name!,
+ Description = request.Description,
+ IsDefault = request.IsDefault ?? false,
+ Enabled = request.Enabled ?? true,
+ EventKinds = request.EventKinds,
+ MinSeverity = request.MinSeverity,
+ Levels = request.Levels!.Select((l, i) => new EscalationLevel
+ {
+ Level = l.Level ?? i + 1,
+ Name = l.Name,
+ EscalateAfter = TimeSpan.FromMinutes(l.EscalateAfterMinutes ?? 15),
+ Targets = l.Targets?.Select(t => new EscalationTarget
+ {
+ Type = Enum.TryParse(t.Type, true, out var type) ? type : EscalationTargetType.User,
+ TargetId = t.TargetId ?? "",
+ Name = t.Name,
+ ChannelId = t.ChannelId
+ }).ToList() ?? [],
+ NotifyMode = Enum.TryParse(l.NotifyMode, true, out var mode) ? mode : EscalationNotifyMode.All,
+ StopOnAck = l.StopOnAck ?? true
+ }).ToList(),
+ ExhaustedAction = Enum.TryParse(request.ExhaustedAction, true, out var action)
+ ? action : EscalationExhaustedAction.RepeatLastLevel,
+ MaxCycles = request.MaxCycles ?? 3
+ };
+
+ private static OnCallSchedule MapToSchedule(OnCallScheduleApiRequest request, string tenantId) => new()
+ {
+ ScheduleId = request.ScheduleId ?? Guid.NewGuid().ToString("N")[..16],
+ TenantId = tenantId,
+ Name = request.Name!,
+ Description = request.Description,
+ Timezone = request.Timezone ?? "UTC",
+ Enabled = request.Enabled ?? true,
+ Layers = request.Layers?.Select(l => new RotationLayer
+ {
+ Name = l.Name ?? "Default",
+ Priority = l.Priority ?? 100,
+ Users = l.Users?.Select((u, i) => new OnCallUser
+ {
+ UserId = u.UserId ?? "",
+ Name = u.Name ?? u.UserId ?? "",
+ Email = u.Email,
+ Phone = u.Phone,
+ PreferredChannelId = u.PreferredChannelId,
+ Order = u.Order ?? i
+ }).ToList() ?? [],
+ Type = Enum.TryParse(l.RotationType, true, out var type) ? type : RotationType.Weekly,
+ HandoffTime = TimeOnly.TryParse(l.HandoffTime, out var time) ? time : new TimeOnly(9, 0),
+ RotationInterval = TimeSpan.FromDays(l.RotationIntervalDays ?? 7),
+ RotationStart = l.RotationStart ?? DateTimeOffset.UtcNow,
+ Restrictions = l.Restrictions?.Select(r => new ScheduleRestriction
+ {
+ Type = Enum.TryParse(r.Type, true, out var rType) ? rType : RestrictionType.DaysOfWeek,
+ DaysOfWeek = r.DaysOfWeek,
+ StartTime = TimeOnly.TryParse(r.StartTime, out var start) ? start : null,
+ EndTime = TimeOnly.TryParse(r.EndTime, out var end) ? end : null
+ }).ToList(),
+ Enabled = l.Enabled ?? true
+ }).ToList() ?? []
+ };
+
+ #endregion
+}
+
+#region API Request Models
+
+public sealed class EscalationPolicyApiRequest
+{
+ public string? PolicyId { get; set; }
+ public string? TenantId { get; set; }
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ public bool? IsDefault { get; set; }
+ public bool? Enabled { get; set; }
+ public List? EventKinds { get; set; }
+ public string? MinSeverity { get; set; }
+ public List? Levels { get; set; }
+ public string? ExhaustedAction { get; set; }
+ public int? MaxCycles { get; set; }
+}
+
+public sealed class EscalationLevelApiRequest
+{
+ public int? Level { get; set; }
+ public string? Name { get; set; }
+ public int? EscalateAfterMinutes { get; set; }
+ public List? Targets { get; set; }
+ public string? NotifyMode { get; set; }
+ public bool? StopOnAck { get; set; }
+}
+
+public sealed class EscalationTargetApiRequest
+{
+ public string? Type { get; set; }
+ public string? TargetId { get; set; }
+ public string? Name { get; set; }
+ public string? ChannelId { get; set; }
+}
+
+public sealed class OnCallScheduleApiRequest
+{
+ public string? ScheduleId { get; set; }
+ public string? TenantId { get; set; }
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ public string? Timezone { get; set; }
+ public bool? Enabled { get; set; }
+ public List? Layers { get; set; }
+}
+
+public sealed class RotationLayerApiRequest
+{
+ public string? Name { get; set; }
+ public int? Priority { get; set; }
+ public List? Users { get; set; }
+ public string? RotationType { get; set; }
+ public string? HandoffTime { get; set; }
+ public int? RotationIntervalDays { get; set; }
+ public DateTimeOffset? RotationStart { get; set; }
+ public List? Restrictions { get; set; }
+ public bool? Enabled { get; set; }
+}
+
+public sealed class OnCallUserApiRequest
+{
+ public string? UserId { get; set; }
+ public string? Name { get; set; }
+ public string? Email { get; set; }
+ public string? Phone { get; set; }
+ public string? PreferredChannelId { get; set; }
+ public int? Order { get; set; }
+}
+
+public sealed class ScheduleRestrictionApiRequest
+{
+ public string? Type { get; set; }
+ public List? DaysOfWeek { get; set; }
+ public string? StartTime { get; set; }
+ public string? EndTime { get; set; }
+}
+
+public sealed class OnCallOverrideApiRequest
+{
+ public string? OverrideId { get; set; }
+ public string? UserId { get; set; }
+ public string? UserName { get; set; }
+ public DateTimeOffset? StartsAt { get; set; }
+ public DateTimeOffset? EndsAt { get; set; }
+ public string? Reason { get; set; }
+}
+
+public sealed class StartEscalationApiRequest
+{
+ public string? PolicyId { get; set; }
+}
+
+public sealed class ManualEscalateApiRequest
+{
+ public string? Reason { get; set; }
+}
+
+public sealed class StopEscalationApiRequest
+{
+ public string? Reason { get; set; }
+}
+
+public sealed class AckApiRequest
+{
+ public string? TenantId { get; set; }
+ public string? IncidentId { get; set; }
+ public string? AcknowledgedBy { get; set; }
+ public string? Comment { get; set; }
+}
+
+#endregion
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/FallbackEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/FallbackEndpoints.cs
new file mode 100644
index 000000000..f9eb7c3af
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/FallbackEndpoints.cs
@@ -0,0 +1,208 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Fallback;
+using StellaOps.Notify.Models;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// REST API endpoints for fallback handler operations.
+///
+public static class FallbackEndpoints
+{
+ ///
+ /// Maps fallback API endpoints.
+ ///
+ public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v2/fallback")
+ .WithTags("Fallback")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ // Get fallback statistics
+ group.MapGet("/statistics", async (
+ int? windowHours,
+ HttpContext context,
+ IFallbackHandler fallbackHandler,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
+
+ var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
+
+ return Results.Ok(new
+ {
+ stats.TenantId,
+ window = stats.Window.ToString(),
+ stats.TotalDeliveries,
+ stats.PrimarySuccesses,
+ stats.FallbackAttempts,
+ stats.FallbackSuccesses,
+ stats.ExhaustedDeliveries,
+ successRate = $"{stats.SuccessRate:P1}",
+ fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
+ failuresByChannel = stats.FailuresByChannel.ToDictionary(
+ kvp => kvp.Key.ToString(),
+ kvp => kvp.Value)
+ });
+ })
+ .WithName("GetFallbackStatistics")
+ .WithSummary("Gets fallback handling statistics for a tenant")
+ .WithDescription(_t("notifier.fallback.stats_description"));
+
+ // Get fallback chain for a channel
+ group.MapGet("/chains/{channelType}", async (
+ NotifyChannelType channelType,
+ HttpContext context,
+ IFallbackHandler fallbackHandler,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ primaryChannel = channelType.ToString(),
+ fallbackChain = chain.Select(c => c.ToString()).ToList(),
+ chainLength = chain.Count
+ });
+ })
+ .WithName("GetFallbackChain")
+ .WithSummary("Gets the fallback chain for a channel type")
+ .WithDescription(_t("notifier.fallback.get_chain_description"));
+
+ // Set fallback chain for a channel
+ group.MapPut("/chains/{channelType}", async (
+ NotifyChannelType channelType,
+ SetFallbackChainRequest request,
+ HttpContext context,
+ IFallbackHandler fallbackHandler,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
+
+ var chain = request.FallbackChain
+ .Select(s => Enum.TryParse(s, out var t) ? t : (NotifyChannelType?)null)
+ .Where(t => t.HasValue)
+ .Select(t => t!.Value)
+ .ToList();
+
+ await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
+
+ return Results.Ok(new
+ {
+ message = _t("notifier.message.fallback_chain_updated"),
+ primaryChannel = channelType.ToString(),
+ fallbackChain = chain.Select(c => c.ToString()).ToList()
+ });
+ })
+ .WithName("SetFallbackChain")
+ .WithSummary("Sets a custom fallback chain for a channel type")
+ .WithDescription(_t("notifier.fallback.set_chain_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ // Test fallback resolution
+ group.MapPost("/test", async (
+ TestFallbackRequest request,
+ HttpContext context,
+ IFallbackHandler fallbackHandler,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ if (!Enum.TryParse(request.FailedChannelType, out var channelType))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.invalid_fallback_channel", request.FailedChannelType) });
+ }
+
+ var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
+
+ // Simulate failure recording
+ await fallbackHandler.RecordFailureAsync(
+ tenantId, deliveryId, channelType, "Test failure", cancellationToken);
+
+ // Get fallback result
+ var result = await fallbackHandler.GetFallbackAsync(
+ tenantId, channelType, deliveryId, cancellationToken);
+
+ // Clean up test state
+ await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
+
+ return Results.Ok(new
+ {
+ testDeliveryId = deliveryId,
+ result.HasFallback,
+ nextChannelType = result.NextChannelType?.ToString(),
+ result.AttemptNumber,
+ result.TotalChannels,
+ result.IsExhausted,
+ result.ExhaustionReason,
+ failedChannels = result.FailedChannels.Select(f => new
+ {
+ channelType = f.ChannelType.ToString(),
+ f.Reason,
+ f.FailedAt,
+ f.AttemptNumber
+ }).ToList()
+ });
+ })
+ .WithName("TestFallback")
+ .WithSummary("Tests fallback resolution without affecting real deliveries")
+ .WithDescription(_t("notifier.fallback.test_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ // Clear delivery state
+ group.MapDelete("/deliveries/{deliveryId}", async (
+ string deliveryId,
+ HttpContext context,
+ IFallbackHandler fallbackHandler,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
+
+ return Results.Ok(new { message = _t("notifier.message.delivery_state_cleared", deliveryId) });
+ })
+ .WithName("ClearDeliveryFallbackState")
+ .WithSummary("Clears fallback state for a specific delivery")
+ .WithDescription(_t("notifier.fallback.clear_delivery_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ return group;
+ }
+}
+
+///
+/// Request to set a custom fallback chain.
+///
+public sealed record SetFallbackChainRequest
+{
+ ///
+ /// Ordered list of fallback channel types.
+ ///
+ public required List FallbackChain { get; init; }
+}
+
+///
+/// Request to test fallback resolution.
+///
+public sealed record TestFallbackRequest
+{
+ ///
+ /// The channel type that "failed".
+ ///
+ public required string FailedChannelType { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentEndpoints.cs
new file mode 100644
index 000000000..2a8b75fe9
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentEndpoints.cs
@@ -0,0 +1,326 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notifier.Worker.Storage;
+using StellaOps.Notify.Models;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// Maps incident (delivery) management endpoints.
+///
+public static class IncidentEndpoints
+{
+ public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/incidents")
+ .WithTags("Incidents")
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ group.MapGet("/", ListIncidentsAsync)
+ .WithName("ListIncidents")
+ .WithSummary("Lists notification incidents (deliveries)")
+ .WithDescription(_t("notifier.incident.list2_description"));
+
+ group.MapGet("/{deliveryId}", GetIncidentAsync)
+ .WithName("GetIncident")
+ .WithSummary("Gets an incident by delivery ID")
+ .WithDescription(_t("notifier.incident.get2_description"));
+
+ group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
+ .WithName("AcknowledgeIncident")
+ .WithSummary("Acknowledges an incident")
+ .WithDescription(_t("notifier.incident.ack2_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapGet("/stats", GetIncidentStatsAsync)
+ .WithName("GetIncidentStats")
+ .WithSummary("Gets incident statistics")
+ .WithDescription(_t("notifier.incident.stats_description"));
+
+ return app;
+ }
+
+ private static async Task ListIncidentsAsync(
+ HttpContext context,
+ INotifyDeliveryRepository deliveries,
+ string? status = null,
+ string? kind = null,
+ string? ruleId = null,
+ int? limit = null,
+ string? continuationToken = null,
+ DateTimeOffset? since = null)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ // Query deliveries with filtering
+ var queryResult = await deliveries.QueryAsync(
+ tenantId,
+ since,
+ status,
+ limit ?? 50,
+ continuationToken,
+ context.RequestAborted);
+
+ IEnumerable filtered = queryResult.Items;
+
+ // Apply additional filters not supported by the repository
+ if (!string.IsNullOrWhiteSpace(kind))
+ {
+ filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (!string.IsNullOrWhiteSpace(ruleId))
+ {
+ filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ var response = filtered.Select(MapToDeliveryResponse).ToList();
+
+ // Add continuation token header for pagination
+ if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
+ {
+ context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
+ }
+
+ return Results.Ok(response);
+ }
+
+ private static async Task GetIncidentAsync(
+ HttpContext context,
+ string deliveryId,
+ INotifyDeliveryRepository deliveries)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
+ if (delivery is null)
+ {
+ return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
+ }
+
+ return Results.Ok(MapToDeliveryResponse(delivery));
+ }
+
+ private static async Task AcknowledgeIncidentAsync(
+ HttpContext context,
+ string deliveryId,
+ DeliveryAckRequest request,
+ INotifyDeliveryRepository deliveries,
+ INotifyAuditRepository audit,
+ TimeProvider timeProvider)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+
+ var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
+ if (delivery is null)
+ {
+ return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
+ }
+
+ // Update delivery status based on acknowledgment
+ var newStatus = request.Resolution?.ToLowerInvariant() switch
+ {
+ "resolved" => NotifyDeliveryStatus.Delivered,
+ "dismissed" => NotifyDeliveryStatus.Failed,
+ _ => delivery.Status
+ };
+
+ var attempt = new NotifyDeliveryAttempt(
+ timestamp: timeProvider.GetUtcNow(),
+ status: NotifyDeliveryAttemptStatus.Success,
+ reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
+
+ var updated = NotifyDelivery.Create(
+ deliveryId: delivery.DeliveryId,
+ tenantId: delivery.TenantId,
+ ruleId: delivery.RuleId,
+ actionId: delivery.ActionId,
+ eventId: delivery.EventId,
+ kind: delivery.Kind,
+ status: newStatus,
+ statusReason: request.Comment ?? $"Acknowledged: {request.Resolution}",
+ rendered: delivery.Rendered,
+ attempts: delivery.Attempts.Add(attempt),
+ metadata: delivery.Metadata,
+ createdAt: delivery.CreatedAt,
+ sentAt: delivery.SentAt,
+ completedAt: timeProvider.GetUtcNow());
+
+ await deliveries.UpdateAsync(updated, context.RequestAborted);
+
+ await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
+ {
+ deliveryId,
+ request.Resolution,
+ request.Comment
+ }, timeProvider, context.RequestAborted);
+
+ return Results.Ok(MapToDeliveryResponse(updated));
+ }
+
+ private static async Task GetIncidentStatsAsync(
+ HttpContext context,
+ INotifyDeliveryRepository deliveries)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
+
+ var stats = new DeliveryStatsResponse
+ {
+ Total = allDeliveries.Count,
+ Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
+ Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
+ Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
+ ByKind = allDeliveries
+ .GroupBy(d => d.Kind)
+ .ToDictionary(g => g.Key, g => g.Count()),
+ ByRule = allDeliveries
+ .GroupBy(d => d.RuleId)
+ .ToDictionary(g => g.Key, g => g.Count())
+ };
+
+ return Results.Ok(stats);
+ }
+
+ private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
+ {
+ return new DeliveryResponse
+ {
+ DeliveryId = delivery.DeliveryId,
+ TenantId = delivery.TenantId,
+ RuleId = delivery.RuleId,
+ ActionId = delivery.ActionId,
+ EventId = delivery.EventId.ToString(),
+ Kind = delivery.Kind,
+ Status = delivery.Status.ToString(),
+ StatusReason = delivery.StatusReason,
+ AttemptCount = delivery.Attempts.Length,
+ LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
+ CreatedAt = delivery.CreatedAt,
+ SentAt = delivery.SentAt,
+ CompletedAt = delivery.CompletedAt,
+ Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
+ };
+ }
+
+ private static string? GetTenantId(HttpContext context)
+ {
+ var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
+ return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
+ }
+
+ private static string GetActor(HttpContext context)
+ {
+ var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
+ return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
+ }
+
+ private static async Task AppendAuditAsync(
+ INotifyAuditRepository audit,
+ string tenantId,
+ string actor,
+ string action,
+ string entityId,
+ string entityType,
+ object payload,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var payloadNode = JsonSerializer.SerializeToNode(payload) as JsonObject;
+ var data = new Dictionary(StringComparer.Ordinal)
+ {
+ ["entityId"] = entityId,
+ ["entityType"] = entityType,
+ ["payload"] = payloadNode?.ToJsonString() ?? "{}"
+ };
+
+ await audit.AppendAsync(tenantId, action, actor, data, cancellationToken);
+ }
+ catch
+ {
+ // Ignore audit failures
+ }
+ }
+
+ private static object Error(string code, string message, HttpContext context) => new
+ {
+ error = new
+ {
+ code,
+ message,
+ traceId = context.TraceIdentifier
+ }
+ };
+}
+
+///
+/// Delivery acknowledgment request for v2 API.
+///
+public sealed record DeliveryAckRequest
+{
+ public string? Resolution { get; init; }
+ public string? Comment { get; init; }
+}
+
+///
+/// Delivery response DTO for v2 API.
+///
+public sealed record DeliveryResponse
+{
+ public required string DeliveryId { get; init; }
+ public required string TenantId { get; init; }
+ public required string RuleId { get; init; }
+ public required string ActionId { get; init; }
+ public required string EventId { get; init; }
+ public required string Kind { get; init; }
+ public required string Status { get; init; }
+ public string? StatusReason { get; init; }
+ public required int AttemptCount { get; init; }
+ public DateTimeOffset? LastAttempt { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public DateTimeOffset? SentAt { get; init; }
+ public DateTimeOffset? CompletedAt { get; init; }
+ public Dictionary? Metadata { get; init; }
+}
+
+///
+/// Delivery statistics response for v2 API.
+///
+public sealed record DeliveryStatsResponse
+{
+ public required int Total { get; init; }
+ public required int Pending { get; init; }
+ public required int Delivered { get; init; }
+ public required int Failed { get; init; }
+ public required Dictionary ByKind { get; init; }
+ public required Dictionary ByRule { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentLiveFeed.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentLiveFeed.cs
new file mode 100644
index 000000000..ddea599b5
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/IncidentLiveFeed.cs
@@ -0,0 +1,317 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Notify.Models;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// WebSocket live feed for real-time incident updates.
+///
+public static class IncidentLiveFeed
+{
+ private static readonly ConcurrentDictionary> _tenantSubscriptions = new();
+
+ public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
+ {
+ app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
+ return app;
+ }
+
+ private static async Task HandleWebSocketAsync(HttpContext context)
+ {
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ error = new
+ {
+ code = "websocket_required",
+ message = "This endpoint requires a WebSocket connection.",
+ traceId = context.TraceIdentifier
+ }
+ });
+ return;
+ }
+
+ var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ // Try query string fallback for WebSocket clients that can't set headers
+ tenantId = context.Request.Query["tenant"].ToString();
+ }
+
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ error = new
+ {
+ code = "tenant_missing",
+ message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
+ traceId = context.TraceIdentifier
+ }
+ });
+ return;
+ }
+
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+
+ var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag());
+ subscriptions.Add(webSocket);
+
+ try
+ {
+ // Send connection acknowledgment
+ var ackMessage = JsonSerializer.Serialize(new
+ {
+ type = "connected",
+ tenantId,
+ timestamp = DateTimeOffset.UtcNow
+ });
+ await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
+
+ // Keep connection alive and handle incoming messages
+ await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
+ }
+ finally
+ {
+ // Remove from subscriptions
+ var newBag = new ConcurrentBag(
+ subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
+ _tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
+ }
+ }
+
+ private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[4096];
+
+ while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await webSocket.CloseAsync(
+ WebSocketCloseStatus.NormalClosure,
+ "Client initiated close",
+ cancellationToken);
+ break;
+ }
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
+ }
+ }
+ catch (WebSocketException)
+ {
+ break;
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(message);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("type", out var typeElement))
+ {
+ var type = typeElement.GetString();
+
+ switch (type)
+ {
+ case "ping":
+ var pongResponse = JsonSerializer.Serialize(new
+ {
+ type = "pong",
+ timestamp = DateTimeOffset.UtcNow
+ });
+ await SendMessageAsync(webSocket, pongResponse, cancellationToken);
+ break;
+
+ case "subscribe":
+ // Handle filter subscriptions (e.g., specific rule IDs, kinds)
+ var subResponse = JsonSerializer.Serialize(new
+ {
+ type = "subscribed",
+ tenantId,
+ timestamp = DateTimeOffset.UtcNow
+ });
+ await SendMessageAsync(webSocket, subResponse, cancellationToken);
+ break;
+
+ default:
+ var errorResponse = JsonSerializer.Serialize(new
+ {
+ type = "error",
+ message = $"Unknown message type: {type}"
+ });
+ await SendMessageAsync(webSocket, errorResponse, cancellationToken);
+ break;
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ var errorResponse = JsonSerializer.Serialize(new
+ {
+ type = "error",
+ message = "Invalid JSON message"
+ });
+ await SendMessageAsync(webSocket, errorResponse, cancellationToken);
+ }
+ }
+
+ private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
+ {
+ if (webSocket.State != WebSocketState.Open)
+ {
+ return;
+ }
+
+ var bytes = Encoding.UTF8.GetBytes(message);
+ await webSocket.SendAsync(
+ new ArraySegment(bytes),
+ WebSocketMessageType.Text,
+ endOfMessage: true,
+ cancellationToken);
+ }
+
+ ///
+ /// Broadcasts an incident update to all connected clients for the specified tenant.
+ ///
+ public static async Task BroadcastIncidentUpdateAsync(
+ string tenantId,
+ NotifyDelivery delivery,
+ string updateType,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
+ {
+ return;
+ }
+
+ var message = JsonSerializer.Serialize(new
+ {
+ type = "incident_update",
+ updateType, // created, updated, acknowledged, delivered, failed
+ timestamp = DateTimeOffset.UtcNow,
+ incident = new
+ {
+ deliveryId = delivery.DeliveryId,
+ tenantId = delivery.TenantId,
+ ruleId = delivery.RuleId,
+ actionId = delivery.ActionId,
+ eventId = delivery.EventId.ToString(),
+ kind = delivery.Kind,
+ status = delivery.Status.ToString(),
+ statusReason = delivery.StatusReason,
+ attemptCount = delivery.Attempts.Length,
+ createdAt = delivery.CreatedAt,
+ sentAt = delivery.SentAt,
+ completedAt = delivery.CompletedAt
+ }
+ });
+
+ var deadSockets = new List();
+
+ foreach (var socket in subscriptions)
+ {
+ if (socket.State != WebSocketState.Open)
+ {
+ deadSockets.Add(socket);
+ continue;
+ }
+
+ try
+ {
+ await SendMessageAsync(socket, message, cancellationToken);
+ }
+ catch
+ {
+ deadSockets.Add(socket);
+ }
+ }
+
+ // Clean up dead sockets
+ if (deadSockets.Count > 0)
+ {
+ var newBag = new ConcurrentBag(
+ subscriptions.Where(s => !deadSockets.Contains(s)));
+ _tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
+ }
+ }
+
+ ///
+ /// Broadcasts incident statistics update to all connected clients for the specified tenant.
+ ///
+ public static async Task BroadcastStatsUpdateAsync(
+ string tenantId,
+ int total,
+ int pending,
+ int delivered,
+ int failed,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
+ {
+ return;
+ }
+
+ var message = JsonSerializer.Serialize(new
+ {
+ type = "stats_update",
+ timestamp = DateTimeOffset.UtcNow,
+ stats = new
+ {
+ total,
+ pending,
+ delivered,
+ failed
+ }
+ });
+
+ foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
+ {
+ try
+ {
+ await SendMessageAsync(socket, message, cancellationToken);
+ }
+ catch
+ {
+ // Ignore send failures
+ }
+ }
+ }
+
+ ///
+ /// Gets the count of active WebSocket connections for a tenant.
+ ///
+ public static int GetConnectionCount(string tenantId)
+ {
+ if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
+ {
+ return subscriptions.Count(s => s.State == WebSocketState.Open);
+ }
+ return 0;
+ }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/LocalizationEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/LocalizationEndpoints.cs
new file mode 100644
index 000000000..662aa0666
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/LocalizationEndpoints.cs
@@ -0,0 +1,322 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Localization;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// REST API endpoints for localization operations.
+///
+public static class LocalizationEndpoints
+{
+ ///
+ /// Maps localization API endpoints.
+ ///
+ public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v2/localization")
+ .WithTags("Localization")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ // List bundles
+ group.MapGet("/bundles", async (
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ bundles = bundles.Select(b => new
+ {
+ b.BundleId,
+ b.TenantId,
+ b.Locale,
+ b.Namespace,
+ stringCount = b.Strings.Count,
+ b.Priority,
+ b.Enabled,
+ b.Source,
+ b.Description,
+ b.CreatedAt,
+ b.UpdatedAt
+ }).ToList(),
+ count = bundles.Count
+ });
+ })
+ .WithName("ListLocalizationBundles")
+ .WithSummary("Lists all localization bundles for a tenant")
+ .WithDescription(_t("notifier.localization.list_bundles_description"));
+
+ // Get supported locales
+ group.MapGet("/locales", async (
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ locales,
+ count = locales.Count
+ });
+ })
+ .WithName("GetSupportedLocales")
+ .WithSummary("Gets all supported locales for a tenant")
+ .WithDescription(_t("notifier.localization.get_locales_description"));
+
+ // Get bundle contents
+ group.MapGet("/bundles/{locale}", async (
+ string locale,
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ locale,
+ strings,
+ count = strings.Count
+ });
+ })
+ .WithName("GetLocalizationBundle")
+ .WithSummary("Gets all localized strings for a locale")
+ .WithDescription(_t("notifier.localization.get_bundle_description"));
+
+ // Get single string
+ group.MapGet("/strings/{key}", async (
+ string key,
+ string? locale,
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var effectiveLocale = locale ?? "en-US";
+
+ var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ key,
+ locale = effectiveLocale,
+ value
+ });
+ })
+ .WithName("GetLocalizedString")
+ .WithSummary("Gets a single localized string")
+ .WithDescription(_t("notifier.localization.get_string_description"));
+
+ // Format string with parameters
+ group.MapPost("/strings/{key}/format", async (
+ string key,
+ FormatStringRequest request,
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var locale = request.Locale ?? "en-US";
+
+ var parameters = request.Parameters ?? new Dictionary();
+ var value = await localizationService.GetFormattedStringAsync(
+ tenantId, key, locale, parameters, cancellationToken);
+
+ return Results.Ok(new
+ {
+ tenantId,
+ key,
+ locale,
+ formatted = value
+ });
+ })
+ .WithName("FormatLocalizedString")
+ .WithSummary("Gets a localized string with parameter substitution")
+ .WithDescription(_t("notifier.localization.format_string_description"));
+
+ // Create/update bundle
+ group.MapPut("/bundles", async (
+ CreateBundleRequest request,
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
+
+ var bundle = new LocalizationBundle
+ {
+ BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
+ TenantId = tenantId,
+ Locale = request.Locale,
+ Namespace = request.Namespace ?? "default",
+ Strings = request.Strings,
+ Priority = request.Priority,
+ Enabled = request.Enabled,
+ Description = request.Description,
+ Source = "api"
+ };
+
+ var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
+
+ if (!result.Success)
+ {
+ return Results.BadRequest(new { error = result.Error });
+ }
+
+ return result.IsNew
+ ? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
+ {
+ bundleId = result.BundleId,
+ message = _t("notifier.message.bundle_created")
+ })
+ : Results.Ok(new
+ {
+ bundleId = result.BundleId,
+ message = _t("notifier.message.bundle_updated")
+ });
+ })
+ .WithName("UpsertLocalizationBundle")
+ .WithSummary("Creates or updates a localization bundle")
+ .WithDescription(_t("notifier.localization.upsert_bundle_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ // Delete bundle
+ group.MapDelete("/bundles/{bundleId}", async (
+ string bundleId,
+ HttpContext context,
+ ILocalizationService localizationService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+ var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
+
+ var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
+
+ if (!deleted)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.bundle_not_found", bundleId) });
+ }
+
+ return Results.Ok(new { message = _t("notifier.message.bundle_deleted", bundleId) });
+ })
+ .WithName("DeleteLocalizationBundle")
+ .WithSummary("Deletes a localization bundle")
+ .WithDescription(_t("notifier.localization.delete_bundle_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ // Validate bundle
+ group.MapPost("/bundles/validate", (
+ CreateBundleRequest request,
+ HttpContext context,
+ ILocalizationService localizationService) =>
+ {
+ var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
+
+ var bundle = new LocalizationBundle
+ {
+ BundleId = request.BundleId ?? "validation",
+ TenantId = tenantId,
+ Locale = request.Locale,
+ Namespace = request.Namespace ?? "default",
+ Strings = request.Strings,
+ Priority = request.Priority,
+ Enabled = request.Enabled,
+ Description = request.Description
+ };
+
+ var result = localizationService.Validate(bundle);
+
+ return Results.Ok(new
+ {
+ result.IsValid,
+ result.Errors,
+ result.Warnings
+ });
+ })
+ .WithName("ValidateLocalizationBundle")
+ .WithSummary("Validates a localization bundle without saving")
+ .WithDescription(_t("notifier.localization.validate_bundle_description"));
+
+ return group;
+ }
+}
+
+///
+/// Request to format a localized string.
+///
+public sealed record FormatStringRequest
+{
+ ///
+ /// Target locale.
+ ///
+ public string? Locale { get; init; }
+
+ ///
+ /// Parameters for substitution.
+ ///
+ public Dictionary? Parameters { get; init; }
+}
+
+///
+/// Request to create/update a localization bundle.
+///
+public sealed record CreateBundleRequest
+{
+ ///
+ /// Bundle ID (auto-generated if not provided).
+ ///
+ public string? BundleId { get; init; }
+
+ ///
+ /// Locale code.
+ ///
+ public required string Locale { get; init; }
+
+ ///
+ /// Namespace/category.
+ ///
+ public string? Namespace { get; init; }
+
+ ///
+ /// Localized strings.
+ ///
+ public required Dictionary Strings { get; init; }
+
+ ///
+ /// Bundle priority.
+ ///
+ public int Priority { get; init; }
+
+ ///
+ /// Whether bundle is enabled.
+ ///
+ public bool Enabled { get; init; } = true;
+
+ ///
+ /// Bundle description.
+ ///
+ public string? Description { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/NotifyApiEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/NotifyApiEndpoints.cs
new file mode 100644
index 000000000..afacdcb33
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/NotifyApiEndpoints.cs
@@ -0,0 +1,747 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Contracts;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notifier.Worker.Dispatch;
+using StellaOps.Notifier.Worker.Storage;
+using StellaOps.Notifier.Worker.Templates;
+using StellaOps.Notify.Models;
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// API endpoints for rules, templates, and incidents management.
+///
+public static class NotifyApiEndpoints
+{
+ ///
+ /// Maps all Notify API v2 endpoints.
+ ///
+ public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/notify")
+ .WithTags("Notify")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ // Rules CRUD
+ MapRulesEndpoints(group);
+
+ // Templates CRUD + Preview
+ MapTemplatesEndpoints(group);
+
+ // Incidents
+ MapIncidentsEndpoints(group);
+
+ return app;
+ }
+
+ private static void MapRulesEndpoints(RouteGroupBuilder group)
+ {
+ group.MapGet("/rules", async (
+ HttpContext context,
+ INotifyRuleRepository ruleRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
+ var items = rules.Select(MapRuleToResponse).ToList();
+
+ return Results.Ok(new { items, total = items.Count });
+ })
+ .WithDescription(_t("notifier.rule.list_description"));
+
+ group.MapGet("/rules/{ruleId}", async (
+ HttpContext context,
+ string ruleId,
+ INotifyRuleRepository ruleRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
+ if (rule is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ return Results.Ok(MapRuleToResponse(rule));
+ })
+ .WithDescription(_t("notifier.rule.get_description"));
+
+ group.MapPost("/rules", async (
+ HttpContext context,
+ RuleCreateRequest request,
+ INotifyRuleRepository ruleRepository,
+ INotifyAuditRepository auditRepository,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+ var now = timeProvider.GetUtcNow();
+
+ var rule = MapRequestToRule(request, tenantId, actor, now);
+
+ await ruleRepository.UpsertAsync(rule, cancellationToken);
+
+ await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary
+ {
+ ["ruleId"] = rule.RuleId,
+ ["name"] = rule.Name
+ }, cancellationToken);
+
+ return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
+ })
+ .WithDescription(_t("notifier.rule.create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPut("/rules/{ruleId}", async (
+ HttpContext context,
+ string ruleId,
+ RuleUpdateRequest request,
+ INotifyRuleRepository ruleRepository,
+ INotifyAuditRepository auditRepository,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
+ if (existing is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ var actor = GetActor(context);
+ var now = timeProvider.GetUtcNow();
+
+ var updated = ApplyRuleUpdate(existing, request, actor, now);
+
+ await ruleRepository.UpsertAsync(updated, cancellationToken);
+
+ await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary
+ {
+ ["ruleId"] = updated.RuleId,
+ ["name"] = updated.Name
+ }, cancellationToken);
+
+ return Results.Ok(MapRuleToResponse(updated));
+ })
+ .WithDescription(_t("notifier.rule.update_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapDelete("/rules/{ruleId}", async (
+ HttpContext context,
+ string ruleId,
+ INotifyRuleRepository ruleRepository,
+ INotifyAuditRepository auditRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
+ if (existing is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ var actor = GetActor(context);
+
+ await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken);
+
+ await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary
+ {
+ ["ruleId"] = ruleId
+ }, cancellationToken);
+
+ return Results.NoContent();
+ })
+ .WithDescription(_t("notifier.rule.delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+ }
+
+ private static void MapTemplatesEndpoints(RouteGroupBuilder group)
+ {
+ group.MapGet("/templates", async (
+ HttpContext context,
+ string? keyPrefix,
+ string? channelType,
+ string? locale,
+ int? limit,
+ INotifyTemplateService templateService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ NotifyChannelType? channelTypeEnum = null;
+ if (!string.IsNullOrWhiteSpace(channelType) &&
+ Enum.TryParse(channelType, true, out var parsed))
+ {
+ channelTypeEnum = parsed;
+ }
+
+ var templates = await templateService.ListAsync(tenantId, new TemplateListOptions
+ {
+ KeyPrefix = keyPrefix,
+ ChannelType = channelTypeEnum,
+ Locale = locale,
+ Limit = limit
+ }, cancellationToken);
+
+ var response = templates.Select(MapTemplateToResponse).ToList();
+
+ return Results.Ok(response);
+ })
+ .WithDescription(_t("notifier.template.list_description"));
+
+ group.MapGet("/templates/{templateId}", async (
+ HttpContext context,
+ string templateId,
+ INotifyTemplateService templateService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
+ if (template is null)
+ {
+ return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
+ }
+
+ return Results.Ok(MapTemplateToResponse(template));
+ })
+ .WithDescription(_t("notifier.template.get_description"));
+
+ group.MapPost("/templates", async (
+ HttpContext context,
+ TemplateCreateRequest request,
+ INotifyTemplateService templateService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+
+ if (!Enum.TryParse(request.ChannelType, true, out var channelType))
+ {
+ return Results.BadRequest(Error("invalid_channel_type", _t("notifier.error.invalid_channel_type", request.ChannelType), context));
+ }
+
+ var renderMode = NotifyTemplateRenderMode.Markdown;
+ if (!string.IsNullOrWhiteSpace(request.RenderMode) &&
+ Enum.TryParse(request.RenderMode, true, out var parsedMode))
+ {
+ renderMode = parsedMode;
+ }
+
+ var format = NotifyDeliveryFormat.Json;
+ if (!string.IsNullOrWhiteSpace(request.Format) &&
+ Enum.TryParse(request.Format, true, out var parsedFormat))
+ {
+ format = parsedFormat;
+ }
+
+ var template = NotifyTemplate.Create(
+ templateId: request.TemplateId,
+ tenantId: tenantId,
+ channelType: channelType,
+ key: request.Key,
+ locale: request.Locale,
+ body: request.Body,
+ renderMode: renderMode,
+ format: format,
+ description: request.Description,
+ metadata: request.Metadata);
+
+ var result = await templateService.UpsertAsync(template, actor, cancellationToken);
+
+ if (!result.Success)
+ {
+ return Results.BadRequest(Error("template_validation_failed", result.Error ?? _t("notifier.error.template_validation_failed"), context));
+ }
+
+ var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
+
+ return result.IsNew
+ ? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
+ : Results.Ok(MapTemplateToResponse(created!));
+ })
+ .WithDescription(_t("notifier.template.upsert_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapDelete("/templates/{templateId}", async (
+ HttpContext context,
+ string templateId,
+ INotifyTemplateService templateService,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+ var deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken);
+
+ if (!deleted)
+ {
+ return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
+ }
+
+ return Results.NoContent();
+ })
+ .WithDescription(_t("notifier.template.delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/templates/preview", async (
+ HttpContext context,
+ TemplatePreviewRequest request,
+ INotifyTemplateService templateService,
+ INotifyTemplateRenderer templateRenderer,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ NotifyTemplate? template = null;
+ List? warnings = null;
+
+ if (!string.IsNullOrWhiteSpace(request.TemplateId))
+ {
+ template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
+ if (template is null)
+ {
+ return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", request.TemplateId), context));
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
+ {
+ var validation = templateService.Validate(request.TemplateBody);
+ if (!validation.IsValid)
+ {
+ return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context));
+ }
+ warnings = validation.Warnings.ToList();
+
+ var format = NotifyDeliveryFormat.PlainText;
+ if (!string.IsNullOrWhiteSpace(request.OutputFormat) &&
+ Enum.TryParse(request.OutputFormat, true, out var parsedFormat))
+ {
+ format = parsedFormat;
+ }
+
+ template = NotifyTemplate.Create(
+ templateId: "preview",
+ tenantId: tenantId,
+ channelType: NotifyChannelType.Custom,
+ key: "preview",
+ locale: "en-us",
+ body: request.TemplateBody,
+ format: format);
+ }
+ else
+ {
+ return Results.BadRequest(Error("template_required", _t("notifier.error.template_required"), context));
+ }
+
+ var sampleEvent = NotifyEvent.Create(
+ eventId: Guid.NewGuid(),
+ kind: request.EventKind ?? "preview.event",
+ tenant: tenantId,
+ ts: DateTimeOffset.UtcNow,
+ payload: request.SamplePayload ?? new JsonObject(),
+ attributes: request.SampleAttributes ?? new Dictionary(),
+ actor: "preview",
+ version: "1");
+
+ var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken);
+
+ return Results.Ok(new TemplatePreviewResponse
+ {
+ RenderedBody = rendered.Body,
+ RenderedSubject = rendered.Subject,
+ BodyHash = rendered.BodyHash,
+ Format = rendered.Format.ToString(),
+ Warnings = warnings
+ });
+ })
+ .WithDescription(_t("notifier.template.preview_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/templates/validate", (
+ HttpContext context,
+ TemplatePreviewRequest request,
+ INotifyTemplateService templateService) =>
+ {
+ if (string.IsNullOrWhiteSpace(request.TemplateBody))
+ {
+ return Results.BadRequest(Error("template_body_required", _t("notifier.error.template_body_required"), context));
+ }
+
+ var result = templateService.Validate(request.TemplateBody);
+
+ return Results.Ok(new
+ {
+ isValid = result.IsValid,
+ errors = result.Errors,
+ warnings = result.Warnings
+ });
+ })
+ .WithDescription(_t("notifier.template.validate_description"));
+ }
+
+ private static void MapIncidentsEndpoints(RouteGroupBuilder group)
+ {
+ group.MapGet("/incidents", async (
+ HttpContext context,
+ string? status,
+ string? eventKindPrefix,
+ DateTimeOffset? since,
+ DateTimeOffset? until,
+ int? limit,
+ string? cursor,
+ INotifyDeliveryRepository deliveryRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ // For now, return recent deliveries grouped by event kind as "incidents"
+ // Full incident correlation will be implemented in NOTIFY-SVC-39-001
+ var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken);
+ var deliveries = queryResult.Items;
+
+ var incidents = deliveries
+ .GroupBy(d => d.EventId)
+ .Select(g => new IncidentResponse
+ {
+ IncidentId = g.Key.ToString(),
+ TenantId = tenantId,
+ EventKind = g.First().Kind,
+ Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open",
+ Severity = "medium",
+ Title = $"Notification: {g.First().Kind}",
+ Description = null,
+ EventCount = g.Count(),
+ FirstOccurrence = g.Min(d => d.CreatedAt),
+ LastOccurrence = g.Max(d => d.CreatedAt),
+ Labels = null,
+ Metadata = null
+ })
+ .ToList();
+
+ return Results.Ok(new IncidentListResponse
+ {
+ Incidents = incidents,
+ TotalCount = incidents.Count,
+ NextCursor = queryResult.ContinuationToken
+ });
+ })
+ .WithDescription(_t("notifier.incident.list_description"));
+
+ group.MapPost("/incidents/{incidentId}/ack", async (
+ HttpContext context,
+ string incidentId,
+ IncidentAckRequest request,
+ INotifyAuditRepository auditRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = request.Actor ?? GetActor(context);
+
+ await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary
+ {
+ ["incidentId"] = incidentId,
+ ["comment"] = request.Comment ?? ""
+ }, cancellationToken);
+
+ return Results.NoContent();
+ })
+ .WithDescription(_t("notifier.incident.ack_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/incidents/{incidentId}/resolve", async (
+ HttpContext context,
+ string incidentId,
+ IncidentResolveRequest request,
+ INotifyAuditRepository auditRepository,
+ CancellationToken cancellationToken) =>
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = request.Actor ?? GetActor(context);
+
+ await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary
+ {
+ ["incidentId"] = incidentId,
+ ["reason"] = request.Reason ?? "",
+ ["comment"] = request.Comment ?? ""
+ }, cancellationToken);
+
+ return Results.NoContent();
+ })
+ .WithDescription(_t("notifier.incident.resolve_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+ }
+
+ #region Helpers
+
+ private static string? GetTenantId(HttpContext context)
+ {
+ var value = context.Request.Headers["X-StellaOps-Tenant"].ToString();
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static string GetActor(HttpContext context)
+ {
+ return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor
+ ? actor
+ : "api";
+ }
+
+ private static object Error(string code, string message, HttpContext context) => new
+ {
+ error = new
+ {
+ code,
+ message,
+ traceId = context.TraceIdentifier
+ }
+ };
+
+ private static async Task AuditAsync(
+ INotifyAuditRepository repository,
+ string tenantId,
+ string action,
+ string actor,
+ Dictionary metadata,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken);
+ }
+ catch
+ {
+ // Ignore audit failures
+ }
+ }
+
+ #endregion
+
+ #region Mappers
+
+ private static RuleResponse MapRuleToResponse(NotifyRule rule)
+ {
+ return new RuleResponse
+ {
+ RuleId = rule.RuleId,
+ TenantId = rule.TenantId,
+ Name = rule.Name,
+ Description = rule.Description,
+ Enabled = rule.Enabled,
+ Match = new RuleMatchResponse
+ {
+ EventKinds = rule.Match.EventKinds.ToList(),
+ Namespaces = rule.Match.Namespaces.ToList(),
+ Repositories = rule.Match.Repositories.ToList(),
+ Digests = rule.Match.Digests.ToList(),
+ Labels = rule.Match.Labels.ToList(),
+ ComponentPurls = rule.Match.ComponentPurls.ToList(),
+ MinSeverity = rule.Match.MinSeverity,
+ Verdicts = rule.Match.Verdicts.ToList(),
+ KevOnly = rule.Match.KevOnly ?? false
+ },
+ Actions = rule.Actions.Select(a => new RuleActionResponse
+ {
+ ActionId = a.ActionId,
+ Channel = a.Channel,
+ Template = a.Template,
+ Digest = a.Digest,
+ Throttle = a.Throttle?.ToString(),
+ Locale = a.Locale,
+ Enabled = a.Enabled,
+ Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value)
+ }).ToList(),
+ Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
+ Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
+ CreatedBy = rule.CreatedBy,
+ CreatedAt = rule.CreatedAt,
+ UpdatedBy = rule.UpdatedBy,
+ UpdatedAt = rule.UpdatedAt
+ };
+ }
+
+ private static NotifyRule MapRequestToRule(
+ RuleCreateRequest request,
+ string tenantId,
+ string actor,
+ DateTimeOffset now)
+ {
+ var match = NotifyRuleMatch.Create(
+ eventKinds: request.Match.EventKinds,
+ namespaces: request.Match.Namespaces,
+ repositories: request.Match.Repositories,
+ digests: request.Match.Digests,
+ labels: request.Match.Labels,
+ componentPurls: request.Match.ComponentPurls,
+ minSeverity: request.Match.MinSeverity,
+ verdicts: request.Match.Verdicts,
+ kevOnly: request.Match.KevOnly);
+
+ var actions = request.Actions.Select(a => NotifyRuleAction.Create(
+ actionId: a.ActionId,
+ channel: a.Channel,
+ template: a.Template,
+ digest: a.Digest,
+ throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
+ locale: a.Locale,
+ enabled: a.Enabled,
+ metadata: a.Metadata));
+
+ return NotifyRule.Create(
+ ruleId: request.RuleId,
+ tenantId: tenantId,
+ name: request.Name,
+ match: match,
+ actions: actions,
+ enabled: request.Enabled,
+ description: request.Description,
+ labels: request.Labels,
+ metadata: request.Metadata,
+ createdBy: actor,
+ createdAt: now,
+ updatedBy: actor,
+ updatedAt: now);
+ }
+
+ private static NotifyRule ApplyRuleUpdate(
+ NotifyRule existing,
+ RuleUpdateRequest request,
+ string actor,
+ DateTimeOffset now)
+ {
+ var match = request.Match is not null
+ ? NotifyRuleMatch.Create(
+ eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(),
+ namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(),
+ repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(),
+ digests: request.Match.Digests ?? existing.Match.Digests.ToList(),
+ labels: request.Match.Labels ?? existing.Match.Labels.ToList(),
+ componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(),
+ minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
+ verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(),
+ kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
+ : existing.Match;
+
+ var actions = request.Actions is not null
+ ? request.Actions.Select(a => NotifyRuleAction.Create(
+ actionId: a.ActionId,
+ channel: a.Channel,
+ template: a.Template,
+ digest: a.Digest,
+ throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
+ locale: a.Locale,
+ enabled: a.Enabled,
+ metadata: a.Metadata))
+ : existing.Actions;
+
+ return NotifyRule.Create(
+ ruleId: existing.RuleId,
+ tenantId: existing.TenantId,
+ name: request.Name ?? existing.Name,
+ match: match,
+ actions: actions,
+ enabled: request.Enabled ?? existing.Enabled,
+ description: request.Description ?? existing.Description,
+ labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
+ metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
+ createdBy: existing.CreatedBy,
+ createdAt: existing.CreatedAt,
+ updatedBy: actor,
+ updatedAt: now);
+ }
+
+ private static TemplateResponse MapTemplateToResponse(NotifyTemplate template)
+ {
+ return new TemplateResponse
+ {
+ TemplateId = template.TemplateId,
+ TenantId = template.TenantId,
+ Key = template.Key,
+ ChannelType = template.ChannelType.ToString(),
+ Locale = template.Locale,
+ Body = template.Body,
+ RenderMode = template.RenderMode.ToString(),
+ Format = template.Format.ToString(),
+ Description = template.Description,
+ Metadata = template.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
+ CreatedBy = template.CreatedBy,
+ CreatedAt = template.CreatedAt,
+ UpdatedBy = template.UpdatedBy,
+ UpdatedAt = template.UpdatedAt
+ };
+ }
+
+ #endregion
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/ObservabilityEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/ObservabilityEndpoints.cs
new file mode 100644
index 000000000..48993a1a1
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/ObservabilityEndpoints.cs
@@ -0,0 +1,578 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notifier.Worker.Observability;
+using StellaOps.Notifier.Worker.Retention;
+using System.Linq;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// REST API endpoints for observability services.
+///
+public static class ObservabilityEndpoints
+{
+ ///
+ /// Maps observability endpoints.
+ ///
+ public static IEndpointRouteBuilder MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v1/observability")
+ .WithTags("Observability")
+ .RequireAuthorization(NotifierPolicies.NotifyViewer);
+
+ // Metrics endpoints
+ group.MapGet("/metrics", GetMetricsSnapshot)
+ .WithName("GetMetricsSnapshot")
+ .WithSummary("Gets current metrics snapshot")
+ .WithDescription(_t("notifier.observability.metrics_description"));
+
+ group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
+ .WithName("GetTenantMetrics")
+ .WithSummary("Gets metrics for a specific tenant")
+ .WithDescription(_t("notifier.observability.tenant_metrics_description"));
+
+ // Dead letter endpoints
+ group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
+ .WithName("GetDeadLetters")
+ .WithSummary("Lists dead letter entries for a tenant")
+ .WithDescription(_t("notifier.observability.dead_letters_description"));
+
+ group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
+ .WithName("GetDeadLetterEntry")
+ .WithSummary("Gets a specific dead letter entry")
+ .WithDescription(_t("notifier.observability.dead_letter_get_description"));
+
+ group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
+ .WithName("RetryDeadLetter")
+ .WithSummary("Retries a dead letter entry")
+ .WithDescription(_t("notifier.observability.dead_letter_retry_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
+ .WithName("DiscardDeadLetter")
+ .WithSummary("Discards a dead letter entry")
+ .WithDescription(_t("notifier.observability.dead_letter_discard_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
+ .WithName("GetDeadLetterStats")
+ .WithSummary("Gets dead letter statistics")
+ .WithDescription(_t("notifier.observability.dead_letter_stats_description"));
+
+ group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
+ .WithName("PurgeDeadLetters")
+ .WithSummary("Purges old dead letter entries")
+ .WithDescription(_t("notifier.observability.dead_letter_purge_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ // Chaos testing endpoints
+ group.MapGet("/chaos/experiments", ListChaosExperiments)
+ .WithName("ListChaosExperiments")
+ .WithSummary("Lists chaos experiments")
+ .WithDescription(_t("notifier.observability.chaos_list_description"));
+
+ group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
+ .WithName("GetChaosExperiment")
+ .WithSummary("Gets a chaos experiment")
+ .WithDescription(_t("notifier.observability.chaos_get_description"));
+
+ group.MapPost("/chaos/experiments", StartChaosExperiment)
+ .WithName("StartChaosExperiment")
+ .WithSummary("Starts a new chaos experiment")
+ .WithDescription(_t("notifier.observability.chaos_start_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
+ .WithName("StopChaosExperiment")
+ .WithSummary("Stops a running chaos experiment")
+ .WithDescription(_t("notifier.observability.chaos_stop_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
+ .WithName("GetChaosResults")
+ .WithSummary("Gets chaos experiment results")
+ .WithDescription(_t("notifier.observability.chaos_results_description"));
+
+ // Retention policy endpoints
+ group.MapGet("/retention/policies", ListRetentionPolicies)
+ .WithName("ListRetentionPolicies")
+ .WithSummary("Lists retention policies")
+ .WithDescription(_t("notifier.observability.retention_list_description"));
+
+ group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
+ .WithName("GetRetentionPolicy")
+ .WithSummary("Gets a retention policy")
+ .WithDescription(_t("notifier.observability.retention_get_description"));
+
+ group.MapPost("/retention/policies", CreateRetentionPolicy)
+ .WithName("CreateRetentionPolicy")
+ .WithSummary("Creates a retention policy")
+ .WithDescription(_t("notifier.observability.retention_create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
+ .WithName("UpdateRetentionPolicy")
+ .WithSummary("Updates a retention policy")
+ .WithDescription(_t("notifier.observability.retention_update_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
+ .WithName("DeleteRetentionPolicy")
+ .WithSummary("Deletes a retention policy")
+ .WithDescription(_t("notifier.observability.retention_delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapPost("/retention/execute", ExecuteRetention)
+ .WithName("ExecuteRetention")
+ .WithSummary("Executes retention policies")
+ .WithDescription(_t("notifier.observability.retention_execute_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin);
+
+ group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
+ .WithName("PreviewRetention")
+ .WithSummary("Previews retention policy effects")
+ .WithDescription(_t("notifier.observability.retention_preview_description"));
+
+ group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
+ .WithName("GetRetentionHistory")
+ .WithSummary("Gets retention execution history")
+ .WithDescription(_t("notifier.observability.retention_history_description"));
+
+ return endpoints;
+ }
+
+ // Metrics handlers
+ private static IResult GetMetricsSnapshot(
+ [FromServices] INotifierMetrics metrics)
+ {
+ var snapshot = metrics.GetSnapshot();
+ return Results.Ok(snapshot);
+ }
+
+ private static IResult GetTenantMetrics(
+ string tenantId,
+ [FromServices] INotifierMetrics metrics)
+ {
+ var snapshot = metrics.GetSnapshot(tenantId);
+ return Results.Ok(snapshot);
+ }
+
+ // Dead letter handlers
+ private static async Task GetDeadLetters(
+ string tenantId,
+ [FromQuery] int limit,
+ [FromQuery] int offset,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ var entries = await handler.GetEntriesAsync(
+ tenantId,
+ limit: limit > 0 ? limit : 100,
+ offset: offset,
+ ct: ct);
+ return Results.Ok(entries);
+ }
+
+ private static async Task GetDeadLetterEntry(
+ string tenantId,
+ string entryId,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ var entry = await handler.GetEntryAsync(tenantId, entryId, ct);
+ if (entry is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.dead_letter_not_found") });
+ }
+ return Results.Ok(entry);
+ }
+
+ private static async Task RetryDeadLetter(
+ string tenantId,
+ string entryId,
+ [FromBody] RetryDeadLetterRequest request,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ var result = await handler.RetryAsync(tenantId, entryId, request.Actor, ct);
+ return Results.Ok(result);
+ }
+
+ private static async Task DiscardDeadLetter(
+ string tenantId,
+ string entryId,
+ [FromBody] DiscardDeadLetterRequest request,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ await handler.DiscardAsync(tenantId, entryId, request.Reason, request.Actor, ct);
+ return Results.NoContent();
+ }
+
+ private static async Task GetDeadLetterStats(
+ string tenantId,
+ [FromQuery] int? windowHours,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ var window = windowHours.HasValue
+ ? TimeSpan.FromHours(windowHours.Value)
+ : (TimeSpan?)null;
+ var stats = await handler.GetStatisticsAsync(tenantId, window, ct);
+ return Results.Ok(stats);
+ }
+
+ private static async Task PurgeDeadLetters(
+ string tenantId,
+ [FromQuery] int olderThanDays,
+ [FromServices] IDeadLetterHandler handler,
+ CancellationToken ct)
+ {
+ var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
+ var count = await handler.PurgeAsync(tenantId, olderThan, ct);
+ return Results.Ok(new { purged = count });
+ }
+
+ // Chaos testing handlers
+ private static async Task ListChaosExperiments(
+ [FromQuery] string? status,
+ [FromQuery] int limit,
+ [FromServices] IChaosTestRunner runner,
+ CancellationToken ct)
+ {
+ ChaosExperimentStatus? parsedStatus = null;
+ if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var s))
+ {
+ parsedStatus = s;
+ }
+
+ var experiments = await runner.ListExperimentsAsync(parsedStatus, limit > 0 ? limit : 100, ct);
+ return Results.Ok(experiments);
+ }
+
+ private static async Task GetChaosExperiment(
+ string experimentId,
+ [FromServices] IChaosTestRunner runner,
+ CancellationToken ct)
+ {
+ var experiment = await runner.GetExperimentAsync(experimentId, ct);
+ if (experiment is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.experiment_not_found") });
+ }
+ return Results.Ok(experiment);
+ }
+
+ private static async Task StartChaosExperiment(
+ [FromBody] ChaosExperimentConfig config,
+ [FromServices] IChaosTestRunner runner,
+ CancellationToken ct)
+ {
+ try
+ {
+ var experiment = await runner.StartExperimentAsync(config, ct);
+ return Results.Created($"/api/v1/observability/chaos/experiments/{experiment.Id}", experiment);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Results.Forbid();
+ }
+ }
+
+ private static async Task StopChaosExperiment(
+ string experimentId,
+ [FromServices] IChaosTestRunner runner,
+ CancellationToken ct)
+ {
+ await runner.StopExperimentAsync(experimentId, ct);
+ return Results.NoContent();
+ }
+
+ private static async Task GetChaosResults(
+ string experimentId,
+ [FromServices] IChaosTestRunner runner,
+ CancellationToken ct)
+ {
+ var results = await runner.GetResultsAsync(experimentId, ct);
+ return Results.Ok(results);
+ }
+
+ // Retention policy handlers
+ private static async Task ListRetentionPolicies(
+ [FromQuery] string? tenantId,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ var policies = await service.ListPoliciesAsync(tenantId, ct);
+ return Results.Ok(policies);
+ }
+
+ private static async Task GetRetentionPolicy(
+ string policyId,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ var policy = await service.GetPolicyAsync(policyId, ct);
+ if (policy is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
+ }
+ return Results.Ok(policy);
+ }
+
+ private static async Task CreateRetentionPolicy(
+ [FromBody] RetentionPolicy policy,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ try
+ {
+ await service.RegisterPolicyAsync(policy, ct);
+ return Results.Created($"/api/v1/observability/retention/policies/{policy.Id}", policy);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.Conflict(new { error = ex.Message });
+ }
+ catch (ArgumentException ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ }
+
+ private static async Task UpdateRetentionPolicy(
+ string policyId,
+ [FromBody] RetentionPolicy policy,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ try
+ {
+ await service.UpdatePolicyAsync(policyId, policy, ct);
+ return Results.Ok(policy);
+ }
+ catch (KeyNotFoundException)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
+ }
+ catch (ArgumentException ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ }
+
+ private static async Task DeleteRetentionPolicy(
+ string policyId,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ await service.DeletePolicyAsync(policyId, ct);
+ return Results.NoContent();
+ }
+
+ private static async Task ExecuteRetention(
+ [FromQuery] string? policyId,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ var result = await service.ExecuteRetentionAsync(policyId, ct);
+ return Results.Ok(result);
+ }
+
+ private static async Task PreviewRetention(
+ string policyId,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ try
+ {
+ var preview = await service.PreviewRetentionAsync(policyId, ct);
+ return Results.Ok(preview);
+ }
+ catch (KeyNotFoundException)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
+ }
+ }
+
+ private static async Task GetRetentionHistory(
+ string policyId,
+ [FromQuery] int limit,
+ [FromServices] IRetentionPolicyService service,
+ CancellationToken ct)
+ {
+ var history = await service.GetExecutionHistoryAsync(policyId, limit > 0 ? limit : 100, ct);
+ return Results.Ok(history);
+ }
+}
+
+///
+/// Request to retry a dead letter entry.
+///
+public sealed record RetryDeadLetterRequest
+{
+ ///
+ /// Actor performing the retry.
+ ///
+ public required string Actor { get; init; }
+}
+
+///
+/// Request to discard a dead letter entry.
+///
+public sealed record DiscardDeadLetterRequest
+{
+ ///
+ /// Reason for discarding.
+ ///
+ public required string Reason { get; init; }
+
+ ///
+ /// Actor performing the discard.
+ ///
+ public required string Actor { get; init; }
+}
+
+internal static class DeadLetterHandlerCompatExtensions
+{
+ public static Task> GetEntriesAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ int limit,
+ int offset,
+ CancellationToken ct) =>
+ handler.GetAsync(tenantId, new DeadLetterQuery { Limit = limit, Offset = offset }, ct);
+
+ public static async Task GetEntryAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ string entryId,
+ CancellationToken ct)
+ {
+ var results = await handler.GetAsync(tenantId, new DeadLetterQuery { Limit = 1, Offset = 0, Id = entryId }, ct).ConfigureAwait(false);
+ return results.FirstOrDefault();
+ }
+
+ public static Task RetryAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ string deadLetterId,
+ string? actor,
+ CancellationToken ct) => handler.RetryAsync(tenantId, deadLetterId, ct);
+
+ public static Task DiscardAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ string deadLetterId,
+ string? reason,
+ string? actor,
+ CancellationToken ct) => handler.DiscardAsync(tenantId, deadLetterId, reason, ct);
+
+ public static Task GetStatisticsAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ TimeSpan? window,
+ CancellationToken ct) => handler.GetStatsAsync(tenantId, ct);
+
+ public static Task PurgeAsync(
+ this IDeadLetterHandler handler,
+ string tenantId,
+ TimeSpan olderThan,
+ CancellationToken ct) => Task.FromResult(0);
+}
+
+internal static class RetentionPolicyServiceCompatExtensions
+{
+ private const string DefaultPolicyId = "default";
+
+ public static async Task> ListPoliciesAsync(
+ this IRetentionPolicyService service,
+ string? tenantId,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(tenantId) ? DefaultPolicyId : tenantId;
+ var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
+ return new[] { policy with { Id = id } };
+ }
+
+ public static async Task GetPolicyAsync(
+ this IRetentionPolicyService service,
+ string policyId,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
+ return policy with { Id = id };
+ }
+
+ public static Task RegisterPolicyAsync(
+ this IRetentionPolicyService service,
+ RetentionPolicy policy,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policy.Id) ? DefaultPolicyId : policy.Id;
+ return service.SetPolicyAsync(id, policy with { Id = id }, ct);
+ }
+
+ public static Task UpdatePolicyAsync(
+ this IRetentionPolicyService service,
+ string policyId,
+ RetentionPolicy policy,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ return service.SetPolicyAsync(id, policy with { Id = id }, ct);
+ }
+
+ public static Task DeletePolicyAsync(
+ this IRetentionPolicyService service,
+ string policyId,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ return service.SetPolicyAsync(id, RetentionPolicy.Default with { Id = id }, ct);
+ }
+
+ public static Task ExecuteRetentionAsync(
+ this IRetentionPolicyService service,
+ string? policyId,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ return service.ExecuteCleanupAsync(id, ct);
+ }
+
+ public static Task PreviewRetentionAsync(
+ this IRetentionPolicyService service,
+ string policyId,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ return service.PreviewCleanupAsync(id, ct);
+ }
+
+ public static async Task> GetExecutionHistoryAsync(
+ this IRetentionPolicyService service,
+ string policyId,
+ int limit,
+ CancellationToken ct = default)
+ {
+ var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
+ var last = await service.GetLastExecutionAsync(id, ct).ConfigureAwait(false);
+ if (last is null)
+ {
+ return Array.Empty();
+ }
+
+ return new[] { last };
+ }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/OperatorOverrideEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/OperatorOverrideEndpoints.cs
new file mode 100644
index 000000000..f81979a9b
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/OperatorOverrideEndpoints.cs
@@ -0,0 +1,321 @@
+
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Correlation;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// API endpoints for operator override management.
+///
+public static class OperatorOverrideEndpoints
+{
+ ///
+ /// Maps operator override endpoints.
+ ///
+ public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/overrides")
+ .WithTags("Overrides")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ group.MapGet("/", ListOverridesAsync)
+ .WithName("ListOperatorOverrides")
+ .WithSummary("List active operator overrides")
+ .WithDescription(_t("notifier.override.list_description"));
+
+ group.MapGet("/{overrideId}", GetOverrideAsync)
+ .WithName("GetOperatorOverride")
+ .WithSummary("Get an operator override")
+ .WithDescription(_t("notifier.override.get_description"));
+
+ group.MapPost("/", CreateOverrideAsync)
+ .WithName("CreateOperatorOverride")
+ .WithSummary("Create an operator override")
+ .WithDescription(_t("notifier.override.create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
+ .WithName("RevokeOperatorOverride")
+ .WithSummary("Revoke an operator override")
+ .WithDescription(_t("notifier.override.revoke_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/check", CheckOverrideAsync)
+ .WithName("CheckOperatorOverride")
+ .WithSummary("Check for applicable override")
+ .WithDescription(_t("notifier.override.check_description"));
+
+ return app;
+ }
+
+ private static async Task ListOverridesAsync(
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IOperatorOverrideService overrideService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
+
+ return Results.Ok(overrides.Select(MapToApiResponse));
+ }
+
+ private static async Task GetOverrideAsync(
+ string overrideId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IOperatorOverrideService overrideService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
+
+ if (@override is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
+ }
+
+ return Results.Ok(MapToApiResponse(@override));
+ }
+
+ private static async Task CreateOverrideAsync(
+ [FromBody] OperatorOverrideApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actorHeader,
+ [FromServices] IOperatorOverrideService overrideService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
+ }
+
+ var actor = request.Actor ?? actorHeader;
+ if (string.IsNullOrWhiteSpace(actor))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Reason))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.reason_required") });
+ }
+
+ if (request.DurationMinutes is null or <= 0)
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.duration_required") });
+ }
+
+ var createRequest = new OperatorOverrideCreate
+ {
+ Type = MapOverrideType(request.Type),
+ Reason = request.Reason,
+ Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
+ EffectiveFrom = request.EffectiveFrom,
+ EventKinds = request.EventKinds,
+ CorrelationKeys = request.CorrelationKeys,
+ MaxUsageCount = request.MaxUsageCount
+ };
+
+ try
+ {
+ var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
+ return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
+ }
+ catch (ArgumentException ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.Conflict(new { error = ex.Message });
+ }
+ }
+
+ private static async Task RevokeOverrideAsync(
+ string overrideId,
+ [FromBody] RevokeOverrideApiRequest? request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actorHeader,
+ [FromServices] IOperatorOverrideService overrideService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var actor = request?.Actor ?? actorHeader;
+ if (string.IsNullOrWhiteSpace(actor))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
+ }
+
+ var revoked = await overrideService.RevokeOverrideAsync(
+ tenantId,
+ overrideId,
+ actor,
+ request?.Reason,
+ cancellationToken);
+
+ if (!revoked)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.override_not_found_or_inactive", overrideId) });
+ }
+
+ return Results.NoContent();
+ }
+
+ private static async Task CheckOverrideAsync(
+ [FromBody] CheckOverrideApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromServices] IOperatorOverrideService overrideService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.EventKind))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
+ }
+
+ var result = await overrideService.CheckOverrideAsync(
+ tenantId,
+ request.EventKind,
+ request.CorrelationKey,
+ cancellationToken);
+
+ return Results.Ok(new CheckOverrideApiResponse
+ {
+ HasOverride = result.HasOverride,
+ BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
+ Override = result.Override is not null ? MapToApiResponse(result.Override) : null
+ });
+ }
+
+ private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
+ {
+ "quiethours" or "quiet_hours" => OverrideType.QuietHours,
+ "throttle" => OverrideType.Throttle,
+ "maintenance" => OverrideType.Maintenance,
+ "all" or _ => OverrideType.All
+ };
+
+ private static List MapOverrideTypeToStrings(OverrideType type)
+ {
+ var result = new List();
+ if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
+ if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
+ if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
+ return result;
+ }
+
+ private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
+ {
+ OverrideId = @override.OverrideId,
+ TenantId = @override.TenantId,
+ Type = MapOverrideTypeToStrings(@override.Type),
+ Reason = @override.Reason,
+ EffectiveFrom = @override.EffectiveFrom,
+ ExpiresAt = @override.ExpiresAt,
+ EventKinds = @override.EventKinds.ToList(),
+ CorrelationKeys = @override.CorrelationKeys.ToList(),
+ MaxUsageCount = @override.MaxUsageCount,
+ UsageCount = @override.UsageCount,
+ Status = @override.Status.ToString().ToLowerInvariant(),
+ CreatedBy = @override.CreatedBy,
+ CreatedAt = @override.CreatedAt,
+ RevokedBy = @override.RevokedBy,
+ RevokedAt = @override.RevokedAt,
+ RevocationReason = @override.RevocationReason
+ };
+}
+
+#region API Request/Response Models
+
+///
+/// Request to create an operator override.
+///
+public sealed class OperatorOverrideApiRequest
+{
+ public string? TenantId { get; set; }
+ public string? Actor { get; set; }
+ public string? Type { get; set; }
+ public string? Reason { get; set; }
+ public int? DurationMinutes { get; set; }
+ public DateTimeOffset? EffectiveFrom { get; set; }
+ public List? EventKinds { get; set; }
+ public List? CorrelationKeys { get; set; }
+ public int? MaxUsageCount { get; set; }
+}
+
+///
+/// Request to revoke an operator override.
+///
+public sealed class RevokeOverrideApiRequest
+{
+ public string? Actor { get; set; }
+ public string? Reason { get; set; }
+}
+
+///
+/// Request to check for applicable override.
+///
+public sealed class CheckOverrideApiRequest
+{
+ public string? TenantId { get; set; }
+ public string? EventKind { get; set; }
+ public string? CorrelationKey { get; set; }
+}
+
+///
+/// Response for an operator override.
+///
+public sealed class OperatorOverrideApiResponse
+{
+ public required string OverrideId { get; set; }
+ public required string TenantId { get; set; }
+ public required List Type { get; set; }
+ public required string Reason { get; set; }
+ public required DateTimeOffset EffectiveFrom { get; set; }
+ public required DateTimeOffset ExpiresAt { get; set; }
+ public required List EventKinds { get; set; }
+ public required List CorrelationKeys { get; set; }
+ public int? MaxUsageCount { get; set; }
+ public required int UsageCount { get; set; }
+ public required string Status { get; set; }
+ public required string CreatedBy { get; set; }
+ public required DateTimeOffset CreatedAt { get; set; }
+ public string? RevokedBy { get; set; }
+ public DateTimeOffset? RevokedAt { get; set; }
+ public string? RevocationReason { get; set; }
+}
+
+///
+/// Response for override check.
+///
+public sealed class CheckOverrideApiResponse
+{
+ public required bool HasOverride { get; set; }
+ public required List BypassedTypes { get; set; }
+ public OperatorOverrideApiResponse? Override { get; set; }
+}
+
+#endregion
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/QuietHoursEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/QuietHoursEndpoints.cs
new file mode 100644
index 000000000..da28d38d1
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/QuietHoursEndpoints.cs
@@ -0,0 +1,361 @@
+
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Correlation;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// API endpoints for quiet hours calendar management.
+///
+public static class QuietHoursEndpoints
+{
+ ///
+ /// Maps quiet hours endpoints.
+ ///
+ public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/quiet-hours")
+ .WithTags("QuietHours")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ group.MapGet("/calendars", ListCalendarsAsync)
+ .WithName("ListQuietHoursCalendars")
+ .WithSummary("List all quiet hours calendars")
+ .WithDescription(_t("notifier.quiet_hours.list_description"));
+
+ group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
+ .WithName("GetQuietHoursCalendar")
+ .WithSummary("Get a quiet hours calendar")
+ .WithDescription(_t("notifier.quiet_hours.get_description"));
+
+ group.MapPost("/calendars", CreateCalendarAsync)
+ .WithName("CreateQuietHoursCalendar")
+ .WithSummary("Create a quiet hours calendar")
+ .WithDescription(_t("notifier.quiet_hours.create_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
+ .WithName("UpdateQuietHoursCalendar")
+ .WithSummary("Update a quiet hours calendar")
+ .WithDescription(_t("notifier.quiet_hours.update_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
+ .WithName("DeleteQuietHoursCalendar")
+ .WithSummary("Delete a quiet hours calendar")
+ .WithDescription(_t("notifier.quiet_hours.delete_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPost("/evaluate", EvaluateAsync)
+ .WithName("EvaluateQuietHours")
+ .WithSummary("Evaluate quiet hours")
+ .WithDescription(_t("notifier.quiet_hours.evaluate_description"));
+
+ return app;
+ }
+
+ private static async Task ListCalendarsAsync(
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
+
+ return Results.Ok(calendars.Select(MapToApiResponse));
+ }
+
+ private static async Task GetCalendarAsync(
+ string calendarId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
+
+ if (calendar is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
+ }
+
+ return Results.Ok(MapToApiResponse(calendar));
+ }
+
+ private static async Task CreateCalendarAsync(
+ [FromBody] QuietHoursCalendarApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.calendar_name_required") });
+ }
+
+ if (request.Schedules is null || request.Schedules.Count == 0)
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.calendar_schedules_required") });
+ }
+
+ var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
+
+ var calendar = new QuietHoursCalendar
+ {
+ CalendarId = calendarId,
+ TenantId = tenantId,
+ Name = request.Name,
+ Description = request.Description,
+ Enabled = request.Enabled ?? true,
+ Priority = request.Priority ?? 100,
+ Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
+ ExcludedEventKinds = request.ExcludedEventKinds,
+ IncludedEventKinds = request.IncludedEventKinds
+ };
+
+ var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
+
+ return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
+ }
+
+ private static async Task UpdateCalendarAsync(
+ string calendarId,
+ [FromBody] QuietHoursCalendarApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
+ }
+
+ var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
+ if (existing is null)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
+ }
+
+ var calendar = new QuietHoursCalendar
+ {
+ CalendarId = calendarId,
+ TenantId = tenantId,
+ Name = request.Name ?? existing.Name,
+ Description = request.Description ?? existing.Description,
+ Enabled = request.Enabled ?? existing.Enabled,
+ Priority = request.Priority ?? existing.Priority,
+ Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
+ ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
+ IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
+ CreatedAt = existing.CreatedAt,
+ CreatedBy = existing.CreatedBy
+ };
+
+ var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
+
+ return Results.Ok(MapToApiResponse(updated));
+ }
+
+ private static async Task DeleteCalendarAsync(
+ string calendarId,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantId,
+ [FromHeader(Name = "X-Actor")] string? actor,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
+ }
+
+ var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
+
+ if (!deleted)
+ {
+ return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
+ }
+
+ return Results.NoContent();
+ }
+
+ private static async Task EvaluateAsync(
+ [FromBody] QuietHoursEvaluateApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromServices] IQuietHoursCalendarService calendarService,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
+ }
+
+ if (string.IsNullOrWhiteSpace(request.EventKind))
+ {
+ return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
+ }
+
+ var result = await calendarService.EvaluateAsync(
+ tenantId,
+ request.EventKind,
+ request.EvaluationTime,
+ cancellationToken);
+
+ return Results.Ok(new QuietHoursEvaluateApiResponse
+ {
+ IsActive = result.IsActive,
+ MatchedCalendarId = result.MatchedCalendarId,
+ MatchedCalendarName = result.MatchedCalendarName,
+ MatchedScheduleName = result.MatchedScheduleName,
+ EndsAt = result.EndsAt,
+ Reason = result.Reason
+ });
+ }
+
+ private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
+ {
+ Name = request.Name ?? "Unnamed Schedule",
+ StartTime = request.StartTime ?? "00:00",
+ EndTime = request.EndTime ?? "00:00",
+ DaysOfWeek = request.DaysOfWeek,
+ Timezone = request.Timezone,
+ Enabled = request.Enabled ?? true
+ };
+
+ private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
+ {
+ CalendarId = calendar.CalendarId,
+ TenantId = calendar.TenantId,
+ Name = calendar.Name,
+ Description = calendar.Description,
+ Enabled = calendar.Enabled,
+ Priority = calendar.Priority,
+ Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
+ {
+ Name = s.Name,
+ StartTime = s.StartTime,
+ EndTime = s.EndTime,
+ DaysOfWeek = s.DaysOfWeek?.ToList(),
+ Timezone = s.Timezone,
+ Enabled = s.Enabled
+ }).ToList(),
+ ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
+ IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
+ CreatedAt = calendar.CreatedAt,
+ CreatedBy = calendar.CreatedBy,
+ UpdatedAt = calendar.UpdatedAt,
+ UpdatedBy = calendar.UpdatedBy
+ };
+}
+
+#region API Request/Response Models
+
+///
+/// Request to create or update a quiet hours calendar.
+///
+public sealed class QuietHoursCalendarApiRequest
+{
+ public string? CalendarId { get; set; }
+ public string? TenantId { get; set; }
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ public bool? Enabled { get; set; }
+ public int? Priority { get; set; }
+ public List? Schedules { get; set; }
+ public List? ExcludedEventKinds { get; set; }
+ public List? IncludedEventKinds { get; set; }
+}
+
+///
+/// Schedule entry in a quiet hours calendar request.
+///
+public sealed class QuietHoursScheduleApiRequest
+{
+ public string? Name { get; set; }
+ public string? StartTime { get; set; }
+ public string? EndTime { get; set; }
+ public List? DaysOfWeek { get; set; }
+ public string? Timezone { get; set; }
+ public bool? Enabled { get; set; }
+}
+
+///
+/// Request to evaluate quiet hours.
+///
+public sealed class QuietHoursEvaluateApiRequest
+{
+ public string? TenantId { get; set; }
+ public string? EventKind { get; set; }
+ public DateTimeOffset? EvaluationTime { get; set; }
+}
+
+///
+/// Response for a quiet hours calendar.
+///
+public sealed class QuietHoursCalendarApiResponse
+{
+ public required string CalendarId { get; set; }
+ public required string TenantId { get; set; }
+ public required string Name { get; set; }
+ public string? Description { get; set; }
+ public required bool Enabled { get; set; }
+ public required int Priority { get; set; }
+ public required List Schedules { get; set; }
+ public List? ExcludedEventKinds { get; set; }
+ public List? IncludedEventKinds { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+ public string? CreatedBy { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+ public string? UpdatedBy { get; set; }
+}
+
+///
+/// Schedule entry in a quiet hours calendar response.
+///
+public sealed class QuietHoursScheduleApiResponse
+{
+ public required string Name { get; set; }
+ public required string StartTime { get; set; }
+ public required string EndTime { get; set; }
+ public List? DaysOfWeek { get; set; }
+ public string? Timezone { get; set; }
+ public required bool Enabled { get; set; }
+}
+
+///
+/// Response for quiet hours evaluation.
+///
+public sealed class QuietHoursEvaluateApiResponse
+{
+ public required bool IsActive { get; set; }
+ public string? MatchedCalendarId { get; set; }
+ public string? MatchedCalendarName { get; set; }
+ public string? MatchedScheduleName { get; set; }
+ public DateTimeOffset? EndsAt { get; set; }
+ public string? Reason { get; set; }
+}
+
+#endregion
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/RuleEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/RuleEndpoints.cs
new file mode 100644
index 000000000..2caca21f4
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/RuleEndpoints.cs
@@ -0,0 +1,420 @@
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Contracts;
+using StellaOps.Notifier.Worker.Storage;
+using StellaOps.Notify.Models;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// Maps rule management endpoints.
+///
+public static class RuleEndpoints
+{
+ public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/rules")
+ .WithTags("Rules")
+ .RequireAuthorization(NotifierPolicies.NotifyViewer)
+ .RequireTenant();
+
+ group.MapGet("/", ListRulesAsync)
+ .WithName("ListRules")
+ .WithSummary("Lists all rules for a tenant")
+ .WithDescription(_t("notifier.rule.list2_description"));
+
+ group.MapGet("/{ruleId}", GetRuleAsync)
+ .WithName("GetRule")
+ .WithSummary("Gets a rule by ID")
+ .WithDescription(_t("notifier.rule.get2_description"));
+
+ group.MapPost("/", CreateRuleAsync)
+ .WithName("CreateRule")
+ .WithSummary("Creates a new rule")
+ .WithDescription(_t("notifier.rule.create2_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapPut("/{ruleId}", UpdateRuleAsync)
+ .WithName("UpdateRule")
+ .WithSummary("Updates an existing rule")
+ .WithDescription(_t("notifier.rule.update2_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ group.MapDelete("/{ruleId}", DeleteRuleAsync)
+ .WithName("DeleteRule")
+ .WithSummary("Deletes a rule")
+ .WithDescription(_t("notifier.rule.delete2_description"))
+ .RequireAuthorization(NotifierPolicies.NotifyOperator);
+
+ return app;
+ }
+
+ private static async Task ListRulesAsync(
+ HttpContext context,
+ INotifyRuleRepository rules,
+ bool? enabled = null,
+ string? keyPrefix = null,
+ int? limit = null)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var allRules = await rules.ListAsync(tenantId, context.RequestAborted);
+
+ IEnumerable filtered = allRules;
+
+ if (enabled.HasValue)
+ {
+ filtered = filtered.Where(r => r.Enabled == enabled.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(keyPrefix))
+ {
+ filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (limit.HasValue && limit.Value > 0)
+ {
+ filtered = filtered.Take(limit.Value);
+ }
+
+ var items = filtered.Select(MapToResponse).ToList();
+ return Results.Ok(new { items, total = items.Count });
+ }
+
+ private static async Task GetRuleAsync(
+ HttpContext context,
+ string ruleId,
+ INotifyRuleRepository rules)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
+ if (rule is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ return Results.Ok(MapToResponse(rule));
+ }
+
+ private static async Task CreateRuleAsync(
+ HttpContext context,
+ RuleCreateRequest request,
+ INotifyRuleRepository rules,
+ INotifyAuditRepository audit,
+ TimeProvider timeProvider)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+
+ // Check if rule already exists
+ var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
+ if (existing is not null)
+ {
+ return Results.Conflict(Error("rule_exists", _t("notifier.error.rule_exists", request.RuleId), context));
+ }
+
+ var rule = MapFromRequest(request, tenantId, actor, timeProvider);
+
+ await rules.UpsertAsync(rule, context.RequestAborted);
+
+ await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
+
+ return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
+ }
+
+ private static async Task UpdateRuleAsync(
+ HttpContext context,
+ string ruleId,
+ RuleUpdateRequest request,
+ INotifyRuleRepository rules,
+ INotifyAuditRepository audit,
+ TimeProvider timeProvider)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+
+ var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
+ if (existing is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ var updated = MergeUpdate(existing, request, actor, timeProvider);
+
+ await rules.UpsertAsync(updated, context.RequestAborted);
+
+ await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
+
+ return Results.Ok(MapToResponse(updated));
+ }
+
+ private static async Task DeleteRuleAsync(
+ HttpContext context,
+ string ruleId,
+ INotifyRuleRepository rules,
+ INotifyAuditRepository audit,
+ TimeProvider timeProvider)
+ {
+ var tenantId = GetTenantId(context);
+ if (tenantId is null)
+ {
+ return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
+ }
+
+ var actor = GetActor(context);
+
+ var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
+ if (existing is null)
+ {
+ return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
+ }
+
+ await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
+
+ await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
+
+ return Results.NoContent();
+ }
+
+ private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
+ {
+ var now = timeProvider.GetUtcNow();
+
+ var match = NotifyRuleMatch.Create(
+ eventKinds: request.Match.EventKinds,
+ namespaces: request.Match.Namespaces,
+ repositories: request.Match.Repositories,
+ digests: request.Match.Digests,
+ labels: request.Match.Labels,
+ componentPurls: request.Match.ComponentPurls,
+ minSeverity: request.Match.MinSeverity,
+ verdicts: request.Match.Verdicts,
+ kevOnly: request.Match.KevOnly);
+
+ var actions = request.Actions.Select(a => NotifyRuleAction.Create(
+ actionId: a.ActionId,
+ channel: a.Channel,
+ template: a.Template,
+ digest: a.Digest,
+ throttle: ParseThrottle(a.Throttle),
+ locale: a.Locale,
+ enabled: a.Enabled,
+ metadata: a.Metadata));
+
+ return NotifyRule.Create(
+ ruleId: request.RuleId,
+ tenantId: tenantId,
+ name: request.Name,
+ match: match,
+ actions: actions,
+ enabled: request.Enabled,
+ description: request.Description,
+ labels: request.Labels,
+ metadata: request.Metadata,
+ createdBy: actor,
+ createdAt: now,
+ updatedBy: actor,
+ updatedAt: now);
+ }
+
+ private static NotifyRule MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
+ {
+ var now = timeProvider.GetUtcNow();
+
+ var match = request.Match is not null
+ ? NotifyRuleMatch.Create(
+ eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.AsEnumerable(),
+ namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.AsEnumerable(),
+ repositories: request.Match.Repositories ?? existing.Match.Repositories.AsEnumerable(),
+ digests: request.Match.Digests ?? existing.Match.Digests.AsEnumerable(),
+ labels: request.Match.Labels ?? existing.Match.Labels.AsEnumerable(),
+ componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.AsEnumerable(),
+ minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
+ verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.AsEnumerable(),
+ kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
+ : existing.Match;
+
+ var actions = request.Actions is not null
+ ? request.Actions.Select(a => NotifyRuleAction.Create(
+ actionId: a.ActionId,
+ channel: a.Channel,
+ template: a.Template,
+ digest: a.Digest,
+ throttle: ParseThrottle(a.Throttle),
+ locale: a.Locale,
+ enabled: a.Enabled,
+ metadata: a.Metadata))
+ : existing.Actions;
+
+ return NotifyRule.Create(
+ ruleId: existing.RuleId,
+ tenantId: existing.TenantId,
+ name: request.Name ?? existing.Name,
+ match: match,
+ actions: actions,
+ enabled: request.Enabled ?? existing.Enabled,
+ description: request.Description ?? existing.Description,
+ labels: request.Labels ?? existing.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ metadata: request.Metadata ?? existing.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ createdBy: existing.CreatedBy,
+ createdAt: existing.CreatedAt,
+ updatedBy: actor,
+ updatedAt: now);
+ }
+
+ private static RuleResponse MapToResponse(NotifyRule rule)
+ {
+ return new RuleResponse
+ {
+ RuleId = rule.RuleId,
+ TenantId = rule.TenantId,
+ Name = rule.Name,
+ Description = rule.Description,
+ Enabled = rule.Enabled,
+ Match = new RuleMatchResponse
+ {
+ EventKinds = rule.Match.EventKinds.ToList(),
+ Namespaces = rule.Match.Namespaces.ToList(),
+ Repositories = rule.Match.Repositories.ToList(),
+ Digests = rule.Match.Digests.ToList(),
+ Labels = rule.Match.Labels.ToList(),
+ ComponentPurls = rule.Match.ComponentPurls.ToList(),
+ MinSeverity = rule.Match.MinSeverity,
+ Verdicts = rule.Match.Verdicts.ToList(),
+ KevOnly = rule.Match.KevOnly ?? false
+ },
+ Actions = rule.Actions.Select(a => new RuleActionResponse
+ {
+ ActionId = a.ActionId,
+ Channel = a.Channel,
+ Template = a.Template,
+ Digest = a.Digest,
+ Throttle = a.Throttle?.ToString(),
+ Locale = a.Locale,
+ Enabled = a.Enabled,
+ Metadata = a.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
+ }).ToList(),
+ Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ CreatedBy = rule.CreatedBy,
+ CreatedAt = rule.CreatedAt,
+ UpdatedBy = rule.UpdatedBy,
+ UpdatedAt = rule.UpdatedAt
+ };
+ }
+
+ private static TimeSpan? ParseThrottle(string? throttle)
+ {
+ if (string.IsNullOrWhiteSpace(throttle))
+ {
+ return null;
+ }
+
+ // Try parsing as TimeSpan directly
+ if (TimeSpan.TryParse(throttle, out var ts))
+ {
+ return ts;
+ }
+
+ // Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
+ if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
+ {
+ var value = throttle[2..^1];
+ var unit = throttle[^1];
+
+ if (int.TryParse(value, out var num))
+ {
+ return char.ToUpperInvariant(unit) switch
+ {
+ 'H' => TimeSpan.FromHours(num),
+ 'M' => TimeSpan.FromMinutes(num),
+ 'S' => TimeSpan.FromSeconds(num),
+ _ => null
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private static string? GetTenantId(HttpContext context)
+ {
+ var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
+ return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
+ }
+
+ private static string GetActor(HttpContext context)
+ {
+ var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
+ return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
+ }
+
+ private static async Task AppendAuditAsync(
+ INotifyAuditRepository audit,
+ string tenantId,
+ string actor,
+ string action,
+ string entityId,
+ string entityType,
+ object payload,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var entry = new NotifyAuditEntryDocument
+ {
+ TenantId = tenantId,
+ Actor = actor,
+ Action = action,
+ EntityId = entityId,
+ EntityType = entityType,
+ Timestamp = timeProvider.GetUtcNow(),
+ Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
+ };
+
+ await audit.AppendAsync(entry, cancellationToken);
+ }
+ catch
+ {
+ // Ignore audit failures
+ }
+ }
+
+ private static object Error(string code, string message, HttpContext context) => new
+ {
+ error = new
+ {
+ code,
+ message,
+ traceId = context.TraceIdentifier
+ }
+ };
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/SecurityEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/SecurityEndpoints.cs
new file mode 100644
index 000000000..cd707c133
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/SecurityEndpoints.cs
@@ -0,0 +1,338 @@
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notifier.Worker.Security;
+using static StellaOps.Localization.T;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// REST endpoints for security services.
+///
+public static class SecurityEndpoints
+{
+ public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/v2/security")
+ .WithTags("Security")
+ .RequireAuthorization(NotifierPolicies.NotifyAdmin)
+ .RequireTenant();
+
+ // Signing endpoints
+ group.MapPost("/tokens/sign", SignTokenAsync)
+ .WithName("SignToken")
+ .WithDescription(_t("notifier.security.sign_description"));
+
+ group.MapPost("/tokens/verify", VerifyTokenAsync)
+ .WithName("VerifyToken")
+ .WithDescription("Verifies a signed token and returns the decoded payload if valid. Returns an error if the token is expired, tampered, or issued by a rotated key.");
+
+ group.MapGet("/tokens/{token}/info", GetTokenInfo)
+ .WithName("GetTokenInfo")
+ .WithDescription("Decodes and returns structural information about a token without performing cryptographic verification. Useful for debugging expired or unknown tokens.");
+
+ group.MapPost("/keys/rotate", RotateKeyAsync)
+ .WithName("RotateSigningKey")
+ .WithDescription("Rotates the active signing key. Previously signed tokens remain verifiable during the overlap window. Old keys are retired after the configured grace period.");
+
+ // Webhook security endpoints
+ group.MapPost("/webhooks", RegisterWebhookConfigAsync)
+ .WithName("RegisterWebhookConfig")
+ .WithDescription("Registers or replaces the webhook security configuration for a channel, including the shared secret and allowed IP ranges.");
+
+ group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
+ .WithName("GetWebhookConfig")
+ .WithDescription("Returns the webhook security configuration for a tenant and channel. The secret is not included in the response.");
+
+ group.MapPost("/webhooks/validate", ValidateWebhookAsync)
+ .WithName("ValidateWebhook")
+ .WithDescription("Validates an inbound webhook request against its registered security configuration, verifying the signature and checking the source IP against the allowlist.");
+
+ group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
+ .WithName("UpdateWebhookAllowlist")
+ .WithDescription("Replaces the IP allowlist for a webhook channel. An empty list removes all IP restrictions.");
+
+ // HTML sanitization endpoints
+ group.MapPost("/html/sanitize", SanitizeHtmlAsync)
+ .WithName("SanitizeHtml")
+ .WithDescription("Sanitizes HTML content using the specified profile (or the default profile if omitted), removing disallowed tags and attributes.");
+
+ group.MapPost("/html/validate", ValidateHtmlAsync)
+ .WithName("ValidateHtml")
+ .WithDescription("Validates HTML content against the specified profile and returns whether it is safe, along with details of any disallowed elements found.");
+
+ group.MapPost("/html/strip", StripHtmlTagsAsync)
+ .WithName("StripHtmlTags")
+ .WithDescription("Removes all HTML tags from the input, returning plain text. Useful for generating fallback plain-text notification bodies from HTML templates.");
+
+ // Tenant isolation endpoints
+ group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
+ .WithName("ValidateTenantAccess")
+ .WithDescription("Validates whether the calling tenant is permitted to access the specified resource type and ID for the requested operation. Returns a violation record if access is denied.");
+
+ group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
+ .WithName("GetTenantViolations")
+ .WithDescription("Returns recorded tenant isolation violations for the specified tenant, optionally filtered by time range.");
+
+ group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
+ .WithName("RunTenantFuzzTest")
+ .WithDescription("Runs automated tenant isolation fuzz tests, exercising cross-tenant access paths to surface potential data-leakage vulnerabilities.");
+
+ group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
+ .WithName("GrantCrossTenantAccess")
+ .WithDescription("Grants a target tenant time-bounded access to a resource owned by the owner tenant. Grant records are auditable and expire automatically.");
+
+ group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
+ .WithName("RevokeCrossTenantAccess")
+ .WithDescription("Revokes a previously granted cross-tenant access grant before its expiry. Revocation is immediate and recorded in the audit log.");
+
+ return endpoints;
+ }
+
+ // Signing endpoints
+ private static async Task SignTokenAsync(
+ [FromBody] SignTokenRequest request,
+ [FromServices] ISigningService signingService,
+ CancellationToken cancellationToken)
+ {
+ var payload = new SigningPayload
+ {
+ TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
+ Purpose = request.Purpose,
+ TenantId = request.TenantId,
+ Subject = request.Subject,
+ Target = request.Target,
+ ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
+ Claims = request.Claims ?? new Dictionary()
+ };
+
+ var token = await signingService.SignAsync(payload, cancellationToken);
+ return Results.Ok(new { token });
+ }
+
+ private static async Task VerifyTokenAsync(
+ [FromBody] VerifyTokenRequest request,
+ [FromServices] ISigningService signingService,
+ CancellationToken cancellationToken)
+ {
+ var result = await signingService.VerifyAsync(request.Token, cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static IResult GetTokenInfo(
+ string token,
+ [FromServices] ISigningService signingService)
+ {
+ var info = signingService.GetTokenInfo(token);
+ return info is not null ? Results.Ok(info) : Results.NotFound();
+ }
+
+ private static async Task RotateKeyAsync(
+ [FromServices] ISigningService signingService,
+ CancellationToken cancellationToken)
+ {
+ var success = await signingService.RotateKeyAsync(cancellationToken);
+ return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
+ }
+
+ // Webhook security endpoints
+ private static async Task RegisterWebhookConfigAsync(
+ [FromBody] WebhookSecurityConfig config,
+ [FromServices] IWebhookSecurityService webhookService,
+ CancellationToken cancellationToken)
+ {
+ await webhookService.RegisterWebhookAsync(config, cancellationToken);
+ return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
+ }
+
+ private static async Task GetWebhookConfigAsync(
+ string tenantId,
+ string channelId,
+ [FromServices] IWebhookSecurityService webhookService,
+ CancellationToken cancellationToken)
+ {
+ var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
+ return config is not null ? Results.Ok(config) : Results.NotFound();
+ }
+
+ private static async Task ValidateWebhookAsync(
+ [FromBody] WebhookValidationRequest request,
+ [FromServices] IWebhookSecurityService webhookService,
+ CancellationToken cancellationToken)
+ {
+ var result = await webhookService.ValidateAsync(request, cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task UpdateWebhookAllowlistAsync(
+ string tenantId,
+ string channelId,
+ [FromBody] UpdateAllowlistRequest request,
+ [FromServices] IWebhookSecurityService webhookService,
+ CancellationToken cancellationToken)
+ {
+ await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
+ return Results.NoContent();
+ }
+
+ // HTML sanitization endpoints
+ private static Task SanitizeHtmlAsync(
+ [FromBody] SanitizeHtmlRequest request,
+ [FromServices] IHtmlSanitizer sanitizer)
+ {
+ var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
+ var sanitized = sanitizer.Sanitize(request.Html, profile);
+ return Task.FromResult(Results.Ok(new { sanitized }));
+ }
+
+ private static Task ValidateHtmlAsync(
+ [FromBody] ValidateHtmlRequest request,
+ [FromServices] IHtmlSanitizer sanitizer)
+ {
+ var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
+ var result = sanitizer.Validate(request.Html, profile);
+ return Task.FromResult(Results.Ok(result));
+ }
+
+ private static Task StripHtmlTagsAsync(
+ [FromBody] StripHtmlRequest request,
+ [FromServices] IHtmlSanitizer sanitizer)
+ {
+ var stripped = sanitizer.StripTags(request.Html);
+ return Task.FromResult(Results.Ok(new { text = stripped }));
+ }
+
+ // Tenant isolation endpoints
+ private static async Task ValidateTenantAccessAsync(
+ [FromBody] ValidateTenantAccessRequest request,
+ [FromServices] ITenantIsolationValidator validator,
+ CancellationToken cancellationToken)
+ {
+ var result = await validator.ValidateResourceAccessAsync(
+ request.TenantId,
+ request.ResourceType,
+ request.ResourceId,
+ request.Operation,
+ cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task GetTenantViolationsAsync(
+ string tenantId,
+ [FromQuery] DateTimeOffset? since,
+ [FromServices] ITenantIsolationValidator validator,
+ CancellationToken cancellationToken)
+ {
+ var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
+ return Results.Ok(violations);
+ }
+
+ private static async Task RunTenantFuzzTestAsync(
+ [FromBody] TenantFuzzTestConfig config,
+ [FromServices] ITenantIsolationValidator validator,
+ CancellationToken cancellationToken)
+ {
+ var result = await validator.RunFuzzTestAsync(config, cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task GrantCrossTenantAccessAsync(
+ [FromBody] CrossTenantGrantRequest request,
+ [FromServices] ITenantIsolationValidator validator,
+ CancellationToken cancellationToken)
+ {
+ await validator.GrantCrossTenantAccessAsync(
+ request.OwnerTenantId,
+ request.TargetTenantId,
+ request.ResourceType,
+ request.ResourceId,
+ request.AllowedOperations,
+ request.ExpiresAt,
+ request.GrantedBy,
+ cancellationToken);
+ return Results.Created();
+ }
+
+ private static async Task RevokeCrossTenantAccessAsync(
+ [FromBody] RevokeCrossTenantRequest request,
+ [FromServices] ITenantIsolationValidator validator,
+ CancellationToken cancellationToken)
+ {
+ await validator.RevokeCrossTenantAccessAsync(
+ request.OwnerTenantId,
+ request.TargetTenantId,
+ request.ResourceType,
+ request.ResourceId,
+ request.RevokedBy,
+ cancellationToken);
+ return Results.NoContent();
+ }
+}
+
+// Request DTOs
+public sealed record SignTokenRequest
+{
+ public string? TokenId { get; init; }
+ public required string Purpose { get; init; }
+ public required string TenantId { get; init; }
+ public required string Subject { get; init; }
+ public string? Target { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public Dictionary? Claims { get; init; }
+}
+
+public sealed record VerifyTokenRequest
+{
+ public required string Token { get; init; }
+}
+
+public sealed record UpdateAllowlistRequest
+{
+ public required IReadOnlyList AllowedIps { get; init; }
+ public required string Actor { get; init; }
+}
+
+public sealed record SanitizeHtmlRequest
+{
+ public required string Html { get; init; }
+ public string? Profile { get; init; }
+}
+
+public sealed record ValidateHtmlRequest
+{
+ public required string Html { get; init; }
+ public string? Profile { get; init; }
+}
+
+public sealed record StripHtmlRequest
+{
+ public required string Html { get; init; }
+}
+
+public sealed record ValidateTenantAccessRequest
+{
+ public required string TenantId { get; init; }
+ public required string ResourceType { get; init; }
+ public required string ResourceId { get; init; }
+ public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
+}
+
+public sealed record CrossTenantGrantRequest
+{
+ public required string OwnerTenantId { get; init; }
+ public required string TargetTenantId { get; init; }
+ public required string ResourceType { get; init; }
+ public required string ResourceId { get; init; }
+ public required TenantAccessOperation AllowedOperations { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public required string GrantedBy { get; init; }
+}
+
+public sealed record RevokeCrossTenantRequest
+{
+ public required string OwnerTenantId { get; init; }
+ public required string TargetTenantId { get; init; }
+ public required string ResourceType { get; init; }
+ public required string ResourceId { get; init; }
+ public required string RevokedBy { get; init; }
+}
diff --git a/src/Notify/StellaOps.Notify.WebService/Endpoints/SimulationEndpoints.cs b/src/Notify/StellaOps.Notify.WebService/Endpoints/SimulationEndpoints.cs
new file mode 100644
index 000000000..c1e024229
--- /dev/null
+++ b/src/Notify/StellaOps.Notify.WebService/Endpoints/SimulationEndpoints.cs
@@ -0,0 +1,387 @@
+
+using Microsoft.AspNetCore.Mvc;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Notify.WebService.Constants;
+using StellaOps.Notify.WebService.Extensions;
+using StellaOps.Notifier.Worker.Simulation;
+using StellaOps.Notify.Models;
+using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace StellaOps.Notify.WebService.Endpoints;
+
+///
+/// API endpoints for rule simulation.
+///
+public static class SimulationEndpoints
+{
+ ///
+ /// Maps simulation endpoints.
+ ///
+ public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v2/simulate")
+ .WithTags("Simulation")
+ .WithOpenApi()
+ .RequireAuthorization(NotifierPolicies.NotifyOperator)
+ .RequireTenant();
+
+ group.MapPost("/", SimulateAsync)
+ .WithName("SimulateRules")
+ .WithSummary("Simulate rule evaluation against events")
+ .WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
+
+ group.MapPost("/validate", ValidateRuleAsync)
+ .WithName("ValidateRule")
+ .WithSummary("Validate a rule definition")
+ .WithDescription("Validates a rule definition and returns any errors or warnings.");
+
+ return app;
+ }
+
+ private static async Task SimulateAsync(
+ [FromBody] SimulationApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromServices] ISimulationEngine simulationEngine,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
+ }
+
+ // Convert API events to NotifyEvent
+ var events = request.Events?.Select(MapToNotifyEvent).ToList();
+
+ // Convert API rules to NotifyRule
+ var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
+
+ var simulationRequest = new SimulationRequest
+ {
+ TenantId = tenantId,
+ Events = events,
+ Rules = rules,
+ EnabledRulesOnly = request.EnabledRulesOnly ?? true,
+ HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
+ ? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
+ : null,
+ MaxEvents = request.MaxEvents ?? 100,
+ EventKindFilter = request.EventKindFilter,
+ IncludeNonMatches = request.IncludeNonMatches ?? false,
+ EvaluationTimestamp = request.EvaluationTimestamp
+ };
+
+ var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
+
+ return Results.Ok(MapToApiResponse(result));
+ }
+
+ private static async Task ValidateRuleAsync(
+ [FromBody] RuleApiRequest request,
+ [FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
+ [FromServices] ISimulationEngine simulationEngine,
+ CancellationToken cancellationToken)
+ {
+ var tenantId = request.TenantId ?? tenantIdHeader;
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
+ }
+
+ var rule = MapToNotifyRule(request, tenantId);
+ var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
+
+ return Results.Ok(result);
+ }
+
+ private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
+ {
+ return NotifyEvent.Create(
+ eventId: request.EventId ?? Guid.NewGuid(),
+ kind: request.Kind ?? "unknown",
+ tenant: request.TenantId ?? "default",
+ ts: request.Timestamp ?? DateTimeOffset.UtcNow,
+ payload: request.Payload is not null
+ ? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
+ : null,
+ scope: request.Scope is not null
+ ? NotifyEventScope.Create(
+ @namespace: request.Scope.Namespace,
+ repo: request.Scope.Repo,
+ digest: request.Scope.Digest,
+ component: request.Scope.Component,
+ image: request.Scope.Image,
+ labels: request.Scope.Labels)
+ : null,
+ attributes: request.Attributes);
+ }
+
+ private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
+ {
+ var match = NotifyRuleMatch.Create(
+ eventKinds: request.Match?.EventKinds,
+ namespaces: request.Match?.Namespaces,
+ repositories: request.Match?.Repositories,
+ digests: request.Match?.Digests,
+ labels: request.Match?.Labels,
+ componentPurls: request.Match?.ComponentPurls,
+ minSeverity: request.Match?.MinSeverity,
+ verdicts: request.Match?.Verdicts,
+ kevOnly: request.Match?.KevOnly);
+
+ var actions = request.Actions?.Select(a => NotifyRuleAction.Create(
+ actionId: a.ActionId ?? Guid.NewGuid().ToString("N")[..8],
+ channel: a.Channel ?? "default",
+ template: a.Template,
+ throttle: a.ThrottleSeconds.HasValue
+ ? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
+ : null,
+ enabled: a.Enabled ?? true)).ToList() ?? [];
+
+ return NotifyRule.Create(
+ ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
+ tenantId: request.TenantId ?? tenantId,
+ name: request.Name ?? "Unnamed Rule",
+ match: match,
+ actions: actions,
+ enabled: request.Enabled ?? true,
+ description: request.Description);
+ }
+
+ private static SimulationApiResponse MapToApiResponse(SimulationResult result)
+ {
+ return new SimulationApiResponse
+ {
+ SimulationId = result.SimulationId,
+ ExecutedAt = result.ExecutedAt,
+ TotalEvents = result.TotalEvents,
+ TotalRules = result.TotalRules,
+ MatchedEvents = result.MatchedEvents,
+ TotalActionsTriggered = result.TotalActionsTriggered,
+ DurationMs = result.Duration.TotalMilliseconds,
+ EventResults = result.EventResults.Select(e => new EventResultApiResponse
+ {
+ EventId = e.EventId,
+ EventKind = e.EventKind,
+ EventTimestamp = e.EventTimestamp,
+ Matched = e.Matched,
+ MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
+ {
+ RuleId = r.RuleId,
+ RuleName = r.RuleName,
+ MatchedAt = r.MatchedAt,
+ Actions = r.Actions.Select(a => new ActionMatchApiResponse
+ {
+ ActionId = a.ActionId,
+ Channel = a.Channel,
+ Template = a.Template,
+ Enabled = a.Enabled,
+ ThrottleSeconds = a.Throttle?.TotalSeconds,
+ Explanation = a.Explanation
+ }).ToList()
+ }).ToList(),
+ NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
+ {
+ RuleId = r.RuleId,
+ RuleName = r.RuleName,
+ Reason = r.Reason,
+ Explanation = r.Explanation
+ }).ToList()
+ }).ToList(),
+ RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
+ {
+ RuleId = s.RuleId,
+ RuleName = s.RuleName,
+ Enabled = s.Enabled,
+ MatchCount = s.MatchCount,
+ ActionCount = s.ActionCount,
+ MatchPercentage = s.MatchPercentage,
+ TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
+ {
+ Reason = r.Reason,
+ Explanation = r.Explanation,
+ Count = r.Count
+ }).ToList()
+ }).ToList()
+ };
+ }
+}
+
+#region API Request/Response Models
+
+///
+/// Simulation API request.
+///
+public sealed class SimulationApiRequest
+{
+ public string? TenantId { get; set; }
+ public List? Events { get; set; }
+ public List? Rules { get; set; }
+ public bool? EnabledRulesOnly { get; set; }
+ public int? HistoricalLookbackMinutes { get; set; }
+ public int? MaxEvents { get; set; }
+ public List? EventKindFilter { get; set; }
+ public bool? IncludeNonMatches { get; set; }
+ public DateTimeOffset? EvaluationTimestamp { get; set; }
+}
+
+///
+/// Event for simulation.
+///
+public sealed class EventApiRequest
+{
+ public Guid? EventId { get; set; }
+ public string? Kind { get; set; }
+ public string? TenantId { get; set; }
+ public DateTimeOffset? Timestamp { get; set; }
+ public Dictionary? Payload { get; set; }
+ public EventScopeApiRequest? Scope { get; set; }
+ public Dictionary? Attributes { get; set; }
+}
+
+///
+/// Event scope for simulation.
+///
+public sealed class EventScopeApiRequest
+{
+ public string? Namespace { get; set; }
+ public string? Repo { get; set; }
+ public string? Digest { get; set; }
+ public string? Component { get; set; }
+ public string? Image { get; set; }
+ public Dictionary? Labels { get; set; }
+}
+
+///
+/// Rule for simulation.
+///
+public sealed class RuleApiRequest
+{
+ public string? RuleId { get; set; }
+ public string? TenantId { get; set; }
+ public string? Name { get; set; }
+ public string? Description { get; set; }
+ public bool? Enabled { get; set; }
+ public RuleMatchApiRequest? Match { get; set; }
+ public List? Actions { get; set; }
+}
+
+///
+/// Rule match criteria for simulation.
+///
+public sealed class RuleMatchApiRequest
+{
+ public List? EventKinds { get; set; }
+ public List? Namespaces { get; set; }
+ public List? Repositories { get; set; }
+ public List? Digests { get; set; }
+ public List? Labels { get; set; }
+ public List? ComponentPurls { get; set; }
+ public string? MinSeverity { get; set; }
+ public List? Verdicts { get; set; }
+ public bool? KevOnly { get; set; }
+}
+
+///
+/// Rule action for simulation.
+///
+public sealed class RuleActionApiRequest
+{
+ public string? ActionId { get; set; }
+ public string? Channel { get; set; }
+ public string? Template { get; set; }
+ public int? ThrottleSeconds { get; set; }
+ public bool? Enabled { get; set; }
+}
+
+///
+/// Simulation API response.
+///
+public sealed class SimulationApiResponse
+{
+ public required string SimulationId { get; set; }
+ public required DateTimeOffset ExecutedAt { get; set; }
+ public required int TotalEvents { get; set; }
+ public required int TotalRules { get; set; }
+ public required int MatchedEvents { get; set; }
+ public required int TotalActionsTriggered { get; set; }
+ public required double DurationMs { get; set; }
+ public required List EventResults { get; set; }
+ public required List RuleSummaries { get; set; }
+}
+
+///
+/// Event result in simulation response.
+///
+public sealed class EventResultApiResponse
+{
+ public required Guid EventId { get; set; }
+ public required string EventKind { get; set; }
+ public required DateTimeOffset EventTimestamp { get; set; }
+ public required bool Matched { get; set; }
+ public required List MatchedRules { get; set; }
+ public List? NonMatchedRules { get; set; }
+}
+
+///
+/// Rule match in simulation response.
+///
+public sealed class RuleMatchApiResponse
+{
+ public required string RuleId { get; set; }
+ public required string RuleName { get; set; }
+ public required DateTimeOffset MatchedAt { get; set; }
+ public required List Actions { get; set; }
+}
+
+///
+/// Action match in simulation response.
+///
+public sealed class ActionMatchApiResponse
+{
+ public required string ActionId { get; set; }
+ public required string Channel { get; set; }
+ public string? Template { get; set; }
+ public required bool Enabled { get; set; }
+ public double? ThrottleSeconds { get; set; }
+ public required string Explanation { get; set; }
+}
+
+///
+/// Rule non-match in simulation response.
+///
+public sealed class RuleNonMatchApiResponse
+{
+ public required string RuleId { get; set; }
+ public required string RuleName { get; set; }
+ public required string Reason { get; set; }
+ public required string Explanation { get; set; }
+}
+
+///
+/// Rule summary in simulation response.
+///
+public sealed class RuleSummaryApiResponse
+{
+ public required string RuleId { get; set; }
+ public required string RuleName { get; set; }
+ public required bool Enabled { get; set; }
+ public required int MatchCount { get; set; }
+ public required int ActionCount { get; set; }
+ public required double MatchPercentage { get; set; }
+ public required List TopNonMatchReasons { get; set; }
+}
+
+///