refactor(notify): merge Notifier WebService into Notify WebService

- Delete dead Notify Worker (NoOp handler)
- Move 51 source files (endpoints, contracts, services, compat stores)
- Transform namespaces from Notifier.WebService to Notify.WebService
- Update DI registrations, WebSocket support, v2 endpoint mapping
- Comment out notifier-web in compose, update gateway routes
- Update architecture docs, port registry, rollout matrix
- Notifier Worker stays as separate delivery engine container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 13:17:13 +03:00
parent b3198a66c7
commit 9eec100204
75 changed files with 11218 additions and 1039 deletions

View File

@@ -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" },

View File

@@ -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

View File

@@ -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.

View File

@@ -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.)
---

View File

@@ -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). |

View File

@@ -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).

View File

@@ -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 |

View File

@@ -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

View File

@@ -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` |

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Notify.WebService.Constants;
/// <summary>
/// Named authorization policy constants for Notifier API endpoints.
/// These correspond to scopes defined in <see cref="StellaOps.Auth.Abstractions.StellaOpsScopes"/>.
/// </summary>
public static class NotifierPolicies
{
/// <summary>
/// Read-only access to channels, rules, templates, delivery history, and observability.
/// Maps to scope: notify.viewer
/// </summary>
public const string NotifyViewer = "notify.viewer";
/// <summary>
/// Rule management, channel operations, template authoring, delivery actions, and simulation.
/// Maps to scope: notify.operator
/// </summary>
public const string NotifyOperator = "notify.operator";
/// <summary>
/// Administrative control over security configuration, signing key rotation,
/// tenant isolation grants, retention policies, and platform-wide settings.
/// Maps to scope: notify.admin
/// </summary>
public const string NotifyAdmin = "notify.admin";
/// <summary>
/// Escalation-specific actions: starting, escalating, stopping incidents and
/// managing escalation policies and on-call schedules.
/// Maps to scope: notify.escalate
/// </summary>
public const string NotifyEscalate = "notify.escalate";
}

View File

@@ -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; }
/// <summary>
/// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
/// </summary>
public string? Kind { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public JsonObject? Payload { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? ResumeToken { get; init; }
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
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; }
}

View File

@@ -0,0 +1,137 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
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<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
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; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
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; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
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; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
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<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}

View File

@@ -0,0 +1,109 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// API contracts for delivery history and retry endpoints.
/// Sprint: SPRINT_20251229_018b_FE_notification_delivery_audit
/// Task: NOTIFY-016
/// </summary>
/// <summary>
/// Response for delivery listing.
/// </summary>
public sealed record DeliveryListResponse
{
public required IReadOnlyList<DeliveryResponse> Items { get; init; }
public required int Total { get; init; }
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Individual delivery response.
/// </summary>
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<DeliveryAttemptResponse> 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; }
}
/// <summary>
/// Individual delivery attempt response.
/// </summary>
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; }
}
/// <summary>
/// Request to retry a failed delivery.
/// </summary>
public sealed record DeliveryRetryRequest
{
public string? ForceChannel { get; init; }
public bool BypassThrottle { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Response from retry operation.
/// </summary>
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; }
}
/// <summary>
/// Delivery statistics response.
/// </summary>
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<string, ChannelStatsResponse> ByChannel { get; init; }
public required IReadOnlyDictionary<string, EventKindStatsResponse> ByEventKind { get; init; }
}
/// <summary>
/// Statistics by channel.
/// </summary>
public sealed record ChannelStatsResponse
{
public required int Sent { get; init; }
public required int Failed { get; init; }
}
/// <summary>
/// Statistics by event kind.
/// </summary>
public sealed record EventKindStatsResponse
{
public required int Sent { get; init; }
public required int Failed { get; init; }
}

View File

@@ -0,0 +1,150 @@
using StellaOps.Notify.Models;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
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<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}

View File

@@ -0,0 +1,121 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Incident list query parameters.
/// </summary>
public sealed record IncidentListQuery
{
/// <summary>
/// Filter by status (open, acknowledged, resolved).
/// </summary>
public string? Status { get; init; }
/// <summary>
/// Filter by event kind prefix.
/// </summary>
public string? EventKindPrefix { get; init; }
/// <summary>
/// Filter incidents after this timestamp.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Filter incidents before this timestamp.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Cursor for pagination.
/// </summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Incident response DTO.
/// </summary>
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<string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Incident list response with pagination.
/// </summary>
public sealed record IncidentListResponse
{
public required List<IncidentResponse> Incidents { get; init; }
public required int TotalCount { get; init; }
public string? NextCursor { get; init; }
}
/// <summary>
/// Request to acknowledge an incident.
/// </summary>
public sealed record IncidentAckRequest
{
/// <summary>
/// Actor performing the acknowledgement.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Request to resolve an incident.
/// </summary>
public sealed record IncidentResolveRequest
{
/// <summary>
/// Actor resolving the incident.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Resolution reason.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Delivery history item for an incident.
/// </summary>
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; }
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request payload for acknowledging a pack approval decision.
/// </summary>
public sealed class PackApprovalAckRequest
{
/// <summary>
/// Acknowledgement token from the notification.
/// </summary>
[Required]
[JsonPropertyName("ackToken")]
public string AckToken { get; init; } = string.Empty;
/// <summary>
/// Approval decision: "approved" or "rejected".
/// </summary>
[JsonPropertyName("decision")]
public string? Decision { get; init; }
/// <summary>
/// Optional comment for audit trail.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Identity acknowledging the approval.
/// </summary>
[JsonPropertyName("actor")]
public string? Actor { get; init; }
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request payload for pack approval events from Task Runner.
/// See: docs/notifications/pack-approvals-contract.md
/// </summary>
public sealed class PackApprovalRequest
{
/// <summary>
/// Unique event identifier for deduplication.
/// </summary>
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
/// <summary>
/// Event timestamp in UTC (ISO 8601).
/// </summary>
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Package identifier in PURL format.
/// </summary>
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
/// <summary>
/// Policy metadata (id and version).
/// </summary>
[JsonPropertyName("policy")]
public PackApprovalPolicy? Policy { get; init; }
/// <summary>
/// Current approval state: pending, approved, rejected, hold, expired.
/// </summary>
[JsonPropertyName("decision")]
public string Decision { get; init; } = string.Empty;
/// <summary>
/// Identity that triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
/// <summary>
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
/// </summary>
[JsonPropertyName("resumeToken")]
public string? ResumeToken { get; init; }
/// <summary>
/// Human-readable summary for notifications.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Custom key-value metadata labels.
/// </summary>
[JsonPropertyName("labels")]
public Dictionary<string, string>? Labels { get; init; }
}
/// <summary>
/// Policy metadata associated with a pack approval.
/// </summary>
public sealed class PackApprovalPolicy
{
/// <summary>
/// Policy identifier.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; init; }
/// <summary>
/// Policy version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
}

View File

@@ -0,0 +1,60 @@
using System.Collections.Immutable;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
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<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
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; }
}

View File

@@ -0,0 +1,143 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
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; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
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; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
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<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
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; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}

View File

@@ -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; }
/// <summary>
/// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
/// </summary>
public string? Kind { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public JsonObject? Payload { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? ResumeToken { get; init; }
}

View File

@@ -0,0 +1,128 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to create or update a notification rule.
/// </summary>
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<RuleActionRequest> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to update an existing rule.
/// </summary>
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<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a rule (v2 API).
/// </summary>
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<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule match criteria.
/// </summary>
public sealed record RuleMatchRequest
{
public List<string>? EventKinds { get; init; }
public List<string>? Namespaces { get; init; }
public List<string>? Repositories { get; init; }
public List<string>? Digests { get; init; }
public List<string>? Labels { get; init; }
public List<string>? ComponentPurls { get; init; }
public string? MinSeverity { get; init; }
public List<string>? Verdicts { get; init; }
public bool? KevOnly { get; init; }
}
/// <summary>
/// Rule action configuration.
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule response DTO.
/// </summary>
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<RuleActionResponse> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Rule match response.
/// </summary>
public sealed record RuleMatchResponse
{
public List<string> EventKinds { get; init; } = [];
public List<string> Namespaces { get; init; } = [];
public List<string> Repositories { get; init; } = [];
public List<string> Digests { get; init; } = [];
public List<string> Labels { get; init; } = [];
public List<string> ComponentPurls { get; init; } = [];
public string? MinSeverity { get; init; }
public List<string> Verdicts { get; init; } = [];
public bool KevOnly { get; init; }
}
/// <summary>
/// Rule action response.
/// </summary>
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<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,305 @@
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> 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; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -0,0 +1,150 @@
using StellaOps.Notify.Models;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Request to preview a template rendering.
/// </summary>
public sealed record TemplatePreviewRequest
{
/// <summary>
/// Template ID to preview (mutually exclusive with TemplateBody).
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Raw template body to preview (mutually exclusive with TemplateId).
/// </summary>
public string? TemplateBody { get; init; }
/// <summary>
/// Sample event payload for rendering.
/// </summary>
public JsonObject? SamplePayload { get; init; }
/// <summary>
/// Event kind for context.
/// </summary>
public string? EventKind { get; init; }
/// <summary>
/// Sample attributes.
/// </summary>
public Dictionary<string, string>? SampleAttributes { get; init; }
/// <summary>
/// Output format override.
/// </summary>
public string? OutputFormat { get; init; }
/// <summary>
/// Whether to include provenance links in preview output.
/// </summary>
public bool? IncludeProvenance { get; init; }
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Optional format override for rendering.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
/// <summary>
/// Response from template preview.
/// </summary>
public sealed record TemplatePreviewResponse
{
/// <summary>
/// Rendered body content.
/// </summary>
public required string RenderedBody { get; init; }
/// <summary>
/// Rendered subject (if applicable).
/// </summary>
public string? RenderedSubject { get; init; }
/// <summary>
/// Content hash for deduplication.
/// </summary>
public required string BodyHash { get; init; }
/// <summary>
/// Output format used.
/// </summary>
public required string Format { get; init; }
/// <summary>
/// Validation warnings (if any).
/// </summary>
public List<string>? Warnings { get; init; }
}
/// <summary>
/// Request to create or update a template.
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a template (v2 API).
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Template response DTO.
/// </summary>
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<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Template list query parameters.
/// </summary>
public sealed record TemplateListQuery
{
public string? KeyPrefix { get; init; }
public string? ChannelType { get; init; }
public string? Locale { get; init; }
public int? Limit { get; init; }
}

View File

@@ -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;
/// <summary>
/// API endpoints for escalation management.
/// </summary>
public static class EscalationEndpoints
{
/// <summary>
/// Maps escalation endpoints.
/// </summary>
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> ProcessPagerDutyWebhookAsync(
HttpContext context,
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
[FromServices] IAckBridge ackBridge,
CancellationToken cancellationToken)
{
var pagerDutyAdapter = adapters.OfType<PagerDutyAdapter>().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<IResult> ProcessOpsGenieWebhookAsync(
HttpContext context,
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
[FromServices] IAckBridge ackBridge,
CancellationToken cancellationToken)
{
var opsGenieAdapter = adapters.OfType<OpsGenieAdapter>().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<EscalationTargetType>(t.Type, true, out var type) ? type : EscalationTargetType.User,
TargetId = t.TargetId ?? "",
Name = t.Name,
ChannelId = t.ChannelId
}).ToList() ?? [],
NotifyMode = Enum.TryParse<EscalationNotifyMode>(l.NotifyMode, true, out var mode) ? mode : EscalationNotifyMode.All,
StopOnAck = l.StopOnAck ?? true
}).ToList(),
ExhaustedAction = Enum.TryParse<EscalationExhaustedAction>(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<RotationType>(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<RestrictionType>(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<string>? EventKinds { get; set; }
public string? MinSeverity { get; set; }
public List<EscalationLevelApiRequest>? 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<EscalationTargetApiRequest>? 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<RotationLayerApiRequest>? Layers { get; set; }
}
public sealed class RotationLayerApiRequest
{
public string? Name { get; set; }
public int? Priority { get; set; }
public List<OnCallUserApiRequest>? 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<ScheduleRestrictionApiRequest>? 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<int>? 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

View File

@@ -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;
/// <summary>
/// REST API endpoints for fallback handler operations.
/// </summary>
public static class FallbackEndpoints
{
/// <summary>
/// Maps fallback API endpoints.
/// </summary>
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<NotifyChannelType>(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<NotifyChannelType>(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;
}
}
/// <summary>
/// Request to set a custom fallback chain.
/// </summary>
public sealed record SetFallbackChainRequest
{
/// <summary>
/// Ordered list of fallback channel types.
/// </summary>
public required List<string> FallbackChain { get; init; }
}
/// <summary>
/// Request to test fallback resolution.
/// </summary>
public sealed record TestFallbackRequest
{
/// <summary>
/// The channel type that "failed".
/// </summary>
public required string FailedChannelType { get; init; }
}

View File

@@ -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;
/// <summary>
/// Maps incident (delivery) management endpoints.
/// </summary>
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<IResult> 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<NotifyDelivery> 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<IResult> 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<IResult> 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<IResult> 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<string, string>(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
}
};
}
/// <summary>
/// Delivery acknowledgment request for v2 API.
/// </summary>
public sealed record DeliveryAckRequest
{
public string? Resolution { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Delivery response DTO for v2 API.
/// </summary>
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<string, string>? Metadata { get; init; }
}
/// <summary>
/// Delivery statistics response for v2 API.
/// </summary>
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<string, int> ByKind { get; init; }
public required Dictionary<string, int> ByRule { get; init; }
}

View File

@@ -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;
/// <summary>
/// WebSocket live feed for real-time incident updates.
/// </summary>
public static class IncidentLiveFeed
{
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _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<WebSocket>());
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<WebSocket>(
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<byte>(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<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
/// <summary>
/// Broadcasts an incident update to all connected clients for the specified tenant.
/// </summary>
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<WebSocket>();
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<WebSocket>(
subscriptions.Where(s => !deadSockets.Contains(s)));
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
}
}
/// <summary>
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
/// </summary>
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
}
}
}
/// <summary>
/// Gets the count of active WebSocket connections for a tenant.
/// </summary>
public static int GetConnectionCount(string tenantId)
{
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return subscriptions.Count(s => s.State == WebSocketState.Open);
}
return 0;
}
}

View File

@@ -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;
/// <summary>
/// REST API endpoints for localization operations.
/// </summary>
public static class LocalizationEndpoints
{
/// <summary>
/// Maps localization API endpoints.
/// </summary>
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<string, object>();
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;
}
}
/// <summary>
/// Request to format a localized string.
/// </summary>
public sealed record FormatStringRequest
{
/// <summary>
/// Target locale.
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Parameters for substitution.
/// </summary>
public Dictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record CreateBundleRequest
{
/// <summary>
/// Bundle ID (auto-generated if not provided).
/// </summary>
public string? BundleId { get; init; }
/// <summary>
/// Locale code.
/// </summary>
public required string Locale { get; init; }
/// <summary>
/// Namespace/category.
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Localized strings.
/// </summary>
public required Dictionary<string, string> Strings { get; init; }
/// <summary>
/// Bundle priority.
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Whether bundle is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Bundle description.
/// </summary>
public string? Description { get; init; }
}

View File

@@ -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;
/// <summary>
/// API endpoints for rules, templates, and incidents management.
/// </summary>
public static class NotifyApiEndpoints
{
/// <summary>
/// Maps all Notify API v2 endpoints.
/// </summary>
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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<NotifyChannelType>(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<NotifyChannelType>(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<NotifyTemplateRenderMode>(request.RenderMode, true, out var parsedMode))
{
renderMode = parsedMode;
}
var format = NotifyDeliveryFormat.Json;
if (!string.IsNullOrWhiteSpace(request.Format) &&
Enum.TryParse<NotifyDeliveryFormat>(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<string>? 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<NotifyDeliveryFormat>(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<string, string>(),
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<string, string>
{
["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<string, string>
{
["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<string, string> 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
}

View File

@@ -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;
/// <summary>
/// REST API endpoints for observability services.
/// </summary>
public static class ObservabilityEndpoints
{
/// <summary>
/// Maps observability endpoints.
/// </summary>
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> ListChaosExperiments(
[FromQuery] string? status,
[FromQuery] int limit,
[FromServices] IChaosTestRunner runner,
CancellationToken ct)
{
ChaosExperimentStatus? parsedStatus = null;
if (!string.IsNullOrEmpty(status) && Enum.TryParse<ChaosExperimentStatus>(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<IResult> 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<IResult> 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<IResult> StopChaosExperiment(
string experimentId,
[FromServices] IChaosTestRunner runner,
CancellationToken ct)
{
await runner.StopExperimentAsync(experimentId, ct);
return Results.NoContent();
}
private static async Task<IResult> 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<IResult> ListRetentionPolicies(
[FromQuery] string? tenantId,
[FromServices] IRetentionPolicyService service,
CancellationToken ct)
{
var policies = await service.ListPoliciesAsync(tenantId, ct);
return Results.Ok(policies);
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> DeleteRetentionPolicy(
string policyId,
[FromServices] IRetentionPolicyService service,
CancellationToken ct)
{
await service.DeletePolicyAsync(policyId, ct);
return Results.NoContent();
}
private static async Task<IResult> ExecuteRetention(
[FromQuery] string? policyId,
[FromServices] IRetentionPolicyService service,
CancellationToken ct)
{
var result = await service.ExecuteRetentionAsync(policyId, ct);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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);
}
}
/// <summary>
/// Request to retry a dead letter entry.
/// </summary>
public sealed record RetryDeadLetterRequest
{
/// <summary>
/// Actor performing the retry.
/// </summary>
public required string Actor { get; init; }
}
/// <summary>
/// Request to discard a dead letter entry.
/// </summary>
public sealed record DiscardDeadLetterRequest
{
/// <summary>
/// Reason for discarding.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Actor performing the discard.
/// </summary>
public required string Actor { get; init; }
}
internal static class DeadLetterHandlerCompatExtensions
{
public static Task<IReadOnlyList<DeadLetteredDelivery>> 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<DeadLetteredDelivery?> 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<DeadLetterRetryResult> RetryAsync(
this IDeadLetterHandler handler,
string tenantId,
string deadLetterId,
string? actor,
CancellationToken ct) => handler.RetryAsync(tenantId, deadLetterId, ct);
public static Task<bool> DiscardAsync(
this IDeadLetterHandler handler,
string tenantId,
string deadLetterId,
string? reason,
string? actor,
CancellationToken ct) => handler.DiscardAsync(tenantId, deadLetterId, reason, ct);
public static Task<DeadLetterStats> GetStatisticsAsync(
this IDeadLetterHandler handler,
string tenantId,
TimeSpan? window,
CancellationToken ct) => handler.GetStatsAsync(tenantId, ct);
public static Task<int> 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<IReadOnlyList<RetentionPolicy>> 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<RetentionPolicy?> 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<RetentionCleanupResult> ExecuteRetentionAsync(
this IRetentionPolicyService service,
string? policyId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
return service.ExecuteCleanupAsync(id, ct);
}
public static Task<RetentionCleanupPreview> 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<IReadOnlyList<RetentionCleanupExecution>> 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<RetentionCleanupExecution>();
}
return new[] { last };
}
}

View File

@@ -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;
/// <summary>
/// API endpoints for operator override management.
/// </summary>
public static class OperatorOverrideEndpoints
{
/// <summary>
/// Maps operator override endpoints.
/// </summary>
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<string> MapOverrideTypeToStrings(OverrideType type)
{
var result = new List<string>();
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
/// <summary>
/// Request to create an operator override.
/// </summary>
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<string>? EventKinds { get; set; }
public List<string>? CorrelationKeys { get; set; }
public int? MaxUsageCount { get; set; }
}
/// <summary>
/// Request to revoke an operator override.
/// </summary>
public sealed class RevokeOverrideApiRequest
{
public string? Actor { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Request to check for applicable override.
/// </summary>
public sealed class CheckOverrideApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public string? CorrelationKey { get; set; }
}
/// <summary>
/// Response for an operator override.
/// </summary>
public sealed class OperatorOverrideApiResponse
{
public required string OverrideId { get; set; }
public required string TenantId { get; set; }
public required List<string> Type { get; set; }
public required string Reason { get; set; }
public required DateTimeOffset EffectiveFrom { get; set; }
public required DateTimeOffset ExpiresAt { get; set; }
public required List<string> EventKinds { get; set; }
public required List<string> 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; }
}
/// <summary>
/// Response for override check.
/// </summary>
public sealed class CheckOverrideApiResponse
{
public required bool HasOverride { get; set; }
public required List<string> BypassedTypes { get; set; }
public OperatorOverrideApiResponse? Override { get; set; }
}
#endregion

View File

@@ -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;
/// <summary>
/// API endpoints for quiet hours calendar management.
/// </summary>
public static class QuietHoursEndpoints
{
/// <summary>
/// Maps quiet hours endpoints.
/// </summary>
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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
/// <summary>
/// Request to create or update a quiet hours calendar.
/// </summary>
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<QuietHoursScheduleApiRequest>? Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar request.
/// </summary>
public sealed class QuietHoursScheduleApiRequest
{
public string? Name { get; set; }
public string? StartTime { get; set; }
public string? EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate quiet hours.
/// </summary>
public sealed class QuietHoursEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public DateTimeOffset? EvaluationTime { get; set; }
}
/// <summary>
/// Response for a quiet hours calendar.
/// </summary>
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<QuietHoursScheduleApiResponse> Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar response.
/// </summary>
public sealed class QuietHoursScheduleApiResponse
{
public required string Name { get; set; }
public required string StartTime { get; set; }
public required string EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public required bool Enabled { get; set; }
}
/// <summary>
/// Response for quiet hours evaluation.
/// </summary>
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

View File

@@ -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;
/// <summary>
/// Maps rule management endpoints.
/// </summary>
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<IResult> 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<NotifyRule> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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
}
};
}

View File

@@ -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;
/// <summary>
/// REST endpoints for security services.
/// </summary>
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<IResult> 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<string, string>()
};
var token = await signingService.SignAsync(payload, cancellationToken);
return Results.Ok(new { token });
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> ValidateWebhookAsync(
[FromBody] WebhookValidationRequest request,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
var result = await webhookService.ValidateAsync(request, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> RunTenantFuzzTestAsync(
[FromBody] TenantFuzzTestConfig config,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<string, string>? Claims { get; init; }
}
public sealed record VerifyTokenRequest
{
public required string Token { get; init; }
}
public sealed record UpdateAllowlistRequest
{
public required IReadOnlyList<string> 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; }
}

View File

@@ -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;
/// <summary>
/// API endpoints for rule simulation.
/// </summary>
public static class SimulationEndpoints
{
/// <summary>
/// Maps simulation endpoints.
/// </summary>
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<IResult> 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<IResult> 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
/// <summary>
/// Simulation API request.
/// </summary>
public sealed class SimulationApiRequest
{
public string? TenantId { get; set; }
public List<EventApiRequest>? Events { get; set; }
public List<RuleApiRequest>? Rules { get; set; }
public bool? EnabledRulesOnly { get; set; }
public int? HistoricalLookbackMinutes { get; set; }
public int? MaxEvents { get; set; }
public List<string>? EventKindFilter { get; set; }
public bool? IncludeNonMatches { get; set; }
public DateTimeOffset? EvaluationTimestamp { get; set; }
}
/// <summary>
/// Event for simulation.
/// </summary>
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<string, object>? Payload { get; set; }
public EventScopeApiRequest? Scope { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
}
/// <summary>
/// Event scope for simulation.
/// </summary>
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<string, string>? Labels { get; set; }
}
/// <summary>
/// Rule for simulation.
/// </summary>
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<RuleActionApiRequest>? Actions { get; set; }
}
/// <summary>
/// Rule match criteria for simulation.
/// </summary>
public sealed class RuleMatchApiRequest
{
public List<string>? EventKinds { get; set; }
public List<string>? Namespaces { get; set; }
public List<string>? Repositories { get; set; }
public List<string>? Digests { get; set; }
public List<string>? Labels { get; set; }
public List<string>? ComponentPurls { get; set; }
public string? MinSeverity { get; set; }
public List<string>? Verdicts { get; set; }
public bool? KevOnly { get; set; }
}
/// <summary>
/// Rule action for simulation.
/// </summary>
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; }
}
/// <summary>
/// Simulation API response.
/// </summary>
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<EventResultApiResponse> EventResults { get; set; }
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
}
/// <summary>
/// Event result in simulation response.
/// </summary>
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<RuleMatchApiResponse> MatchedRules { get; set; }
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
}
/// <summary>
/// Rule match in simulation response.
/// </summary>
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<ActionMatchApiResponse> Actions { get; set; }
}
/// <summary>
/// Action match in simulation response.
/// </summary>
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; }
}
/// <summary>
/// Rule non-match in simulation response.
/// </summary>
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; }
}
/// <summary>
/// Rule summary in simulation response.
/// </summary>
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<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
}
/// <summary>
/// Non-match reason summary in simulation response.
/// </summary>
public sealed class NonMatchReasonApiResponse
{
public required string Reason { get; set; }
public required string Explanation { get; set; }
public required int Count { get; set; }
}
#endregion

View File

@@ -0,0 +1,132 @@
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.StormBreaker;
namespace StellaOps.Notify.WebService.Endpoints;
/// <summary>
/// REST API endpoints for storm breaker operations.
/// </summary>
public static class StormBreakerEndpoints
{
/// <summary>
/// Maps storm breaker API endpoints.
/// </summary>
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/storm-breaker")
.WithTags("Storm Breaker")
.WithOpenApi()
.RequireAuthorization(NotifierPolicies.NotifyViewer)
.RequireTenant();
// List active storms for tenant
group.MapGet("/storms", async (
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
activeStorms = storms.Select(s => new
{
s.TenantId,
s.StormKey,
s.StartedAt,
eventCount = s.EventIds.Count,
s.SuppressedCount,
s.LastActivityAt,
s.IsActive
}).ToList(),
count = storms.Count
});
})
.WithName("ListActiveStorms")
.WithSummary("Lists all active notification storms for a tenant")
.WithDescription("Returns all currently active notification storms for the tenant. A storm is declared when the same event kind fires at a rate exceeding the configured threshold, triggering suppression.");
// Get specific storm state
group.MapGet("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
if (state is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(new
{
state.TenantId,
state.StormKey,
state.StartedAt,
eventCount = state.EventIds.Count,
state.SuppressedCount,
state.LastActivityAt,
state.LastSummaryAt,
state.IsActive,
sampleEventIds = state.EventIds.Take(10).ToList()
});
})
.WithName("GetStormState")
.WithSummary("Gets the current state of a specific storm")
.WithDescription("Returns the current state of a storm identified by its storm key, including event count, suppressed count, and the time of the last summarization.");
// Generate storm summary
group.MapPost("/storms/{stormKey}/summary", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
if (summary is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(summary);
})
.WithName("GenerateStormSummary")
.WithSummary("Generates a summary for an active storm")
.WithDescription("Generates and returns a suppression summary notification for the storm, delivering a single digest notification in place of all suppressed individual events.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
// Clear storm state
group.MapDelete("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
})
.WithName("ClearStorm")
.WithSummary("Clears a storm state manually")
.WithDescription("Manually clears the storm state for the specified key. Subsequent events of the same kind will be processed normally until a new storm threshold is exceeded.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
return group;
}
}

View File

@@ -0,0 +1,433 @@
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.Dispatch;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notify.Models;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Endpoints;
/// <summary>
/// Maps template management endpoints.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/templates")
.WithTags("Templates")
.RequireAuthorization(NotifierPolicies.NotifyViewer)
.RequireTenant();
group.MapGet("/", ListTemplatesAsync)
.WithName("ListTemplates")
.WithSummary("Lists all templates for a tenant")
.WithDescription("Returns all notification templates for the tenant with optional filtering by key prefix, channel type, and locale. Templates define rendered message bodies used by alert routing rules.");
group.MapGet("/{templateId}", GetTemplateAsync)
.WithName("GetTemplate")
.WithSummary("Gets a template by ID")
.WithDescription("Returns a single notification template by its identifier, including body, channel type, locale, render mode, format, and audit metadata.");
group.MapPost("/", CreateTemplateAsync)
.WithName("CreateTemplate")
.WithSummary("Creates a new template")
.WithDescription("Creates a new notification template. Template body syntax is validated before persisting. Returns conflict if a template with the same ID already exists.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPut("/{templateId}", UpdateTemplateAsync)
.WithName("UpdateTemplate")
.WithSummary("Updates an existing template")
.WithDescription("Updates an existing notification template. Template body syntax is validated before persisting. An audit entry is written on update.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/{templateId}", DeleteTemplateAsync)
.WithName("DeleteTemplate")
.WithSummary("Deletes a template")
.WithDescription("Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/preview", PreviewTemplateAsync)
.WithName("PreviewTemplate")
.WithSummary("Previews a template rendering")
.WithDescription("Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
return app;
}
private static async Task<IResult> ListTemplatesAsync(
HttpContext context,
INotifyTemplateRepository templates,
string? keyPrefix = null,
string? channelType = null,
string? locale = null,
int? limit = null)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(channelType, true, out var ct))
{
filtered = filtered.Where(t => t.ChannelType == ct);
}
if (!string.IsNullOrWhiteSpace(locale))
{
filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
}
if (limit.HasValue && limit.Value > 0)
{
filtered = filtered.Take(limit.Value);
}
var response = filtered.Select(MapToResponse).ToList();
return Results.Ok(response);
}
private static async Task<IResult> GetTemplateAsync(
HttpContext context,
string templateId,
INotifyTemplateRepository templates)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
return Results.Ok(MapToResponse(template));
}
private static async Task<IResult> CreateTemplateAsync(
HttpContext context,
TemplateCreateRequest request,
INotifyTemplateRepository templates,
INotifyTemplateService? templateService,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
// Validate template body
if (templateService is not null)
{
var validation = templateService.Validate(request.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
}
// Check if template already exists
var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
if (existing is not null)
{
return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context));
}
var template = MapFromRequest(request, tenantId, actor, timeProvider);
await templates.UpsertAsync(template, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted);
return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template));
}
private static async Task<IResult> UpdateTemplateAsync(
HttpContext context,
string templateId,
TemplateCreateRequest request,
INotifyTemplateRepository templates,
INotifyTemplateService? templateService,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
// Validate template body
if (templateService is not null)
{
var validation = templateService.Validate(request.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
}
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing);
await templates.UpsertAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted);
return Results.Ok(MapToResponse(updated));
}
private static async Task<IResult> DeleteTemplateAsync(
HttpContext context,
string templateId,
INotifyTemplateRepository templates,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
await templates.DeleteAsync(tenantId, templateId, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted);
return Results.NoContent();
}
private static async Task<IResult> PreviewTemplateAsync(
HttpContext context,
TemplatePreviewRequest request,
INotifyTemplateRepository templates,
INotifyTemplateRenderer renderer,
INotifyTemplateService? templateService,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
NotifyTemplate? template = null;
if (!string.IsNullOrWhiteSpace(request.TemplateId))
{
template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context));
}
}
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
{
// Create a temporary template for preview
var format = Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var f)
? f
: NotifyDeliveryFormat.PlainText;
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("invalid_request", "Either templateId or templateBody is required.", context));
}
// Validate template body
List<string>? warnings = null;
if (templateService is not null)
{
var validation = templateService.Validate(template.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
warnings = validation.Warnings.ToList();
}
// Create sample event
var sampleEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: request.EventKind ?? "sample.event",
tenant: tenantId,
ts: timeProvider.GetUtcNow(),
payload: request.SamplePayload ?? new JsonObject(),
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
actor: "preview",
version: "1");
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted);
return Results.Ok(new TemplatePreviewResponse
{
RenderedBody = rendered.Body,
RenderedSubject = rendered.Subject,
BodyHash = rendered.BodyHash,
Format = rendered.Format.ToString(),
Warnings = warnings?.Count > 0 ? warnings : null
});
}
private static NotifyTemplate MapFromRequest(
TemplateCreateRequest request,
string tenantId,
string actor,
TimeProvider timeProvider,
NotifyTemplate? existing = null)
{
var now = timeProvider.GetUtcNow();
var channelType = Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var ct)
? ct
: NotifyChannelType.Custom;
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
? rm
: NotifyTemplateRenderMode.Markdown;
var format = Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var f)
? f
: NotifyDeliveryFormat.PlainText;
return 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,
createdBy: existing?.CreatedBy ?? actor,
createdAt: existing?.CreatedAt ?? now,
updatedBy: actor,
updatedAt: now);
}
private static TemplateResponse MapToResponse(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(kvp => kvp.Key, kvp => kvp.Value),
CreatedBy = template.CreatedBy,
CreatedAt = template.CreatedAt,
UpdatedBy = template.UpdatedBy,
UpdatedAt = template.UpdatedAt
};
}
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
}
};
}

View File

@@ -0,0 +1,235 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notify.WebService.Constants;
using StellaOps.Notify.WebService.Extensions;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notify.WebService.Endpoints;
/// <summary>
/// API endpoints for throttle configuration management.
/// </summary>
public static class ThrottleEndpoints
{
/// <summary>
/// Maps throttle configuration endpoints.
/// </summary>
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/throttles")
.WithTags("Throttles")
.WithOpenApi()
.RequireAuthorization(NotifierPolicies.NotifyViewer)
.RequireTenant();
group.MapGet("/config", GetConfigurationAsync)
.WithName("GetThrottleConfiguration")
.WithSummary("Get throttle configuration")
.WithDescription("Returns the throttle configuration for the tenant, including the default suppression window, per-event-kind overrides, and burst window settings. Returns platform defaults if no custom configuration exists.");
group.MapPut("/config", UpdateConfigurationAsync)
.WithName("UpdateThrottleConfiguration")
.WithSummary("Update throttle configuration")
.WithDescription("Creates or replaces the throttle configuration for the tenant. The default duration and optional per-event-kind overrides control how long duplicate notifications are suppressed.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/config", DeleteConfigurationAsync)
.WithName("DeleteThrottleConfiguration")
.WithSummary("Delete throttle configuration")
.WithDescription("Removes the tenant-specific throttle configuration, reverting all throttle windows to the platform defaults.")
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateThrottle")
.WithSummary("Evaluate throttle duration")
.WithDescription("Returns the effective throttle duration in seconds for a given event kind, applying the tenant-specific override if present or the default if not.");
return app;
}
private static async Task<IResult> GetConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
if (config is null)
{
return Results.Ok(new ThrottleConfigurationApiResponse
{
TenantId = tenantId,
DefaultDurationSeconds = 900, // 15 minutes default
Enabled = true,
EventKindOverrides = new Dictionary<string, int>(),
IsDefault = true
});
}
return Results.Ok(MapToApiResponse(config));
}
private static async Task<IResult> UpdateConfigurationAsync(
[FromBody] ThrottleConfigurationApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
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." });
}
if (request.DefaultDurationSeconds is <= 0)
{
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
}
var config = new ThrottleConfiguration
{
TenantId = tenantId,
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
EventKindOverrides = request.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
MaxEventsPerWindow = request.MaxEventsPerWindow,
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
: null,
Enabled = request.Enabled ?? true
};
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
return Results.Ok(MapToApiResponse(updated));
}
private static async Task<IResult> DeleteConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
}
return Results.NoContent();
}
private static async Task<IResult> EvaluateAsync(
[FromBody] ThrottleEvaluateApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IThrottleConfigurationService throttleService,
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." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
tenantId,
request.EventKind,
cancellationToken);
return Results.Ok(new ThrottleEvaluateApiResponse
{
EventKind = request.EventKind,
EffectiveDurationSeconds = (int)duration.TotalSeconds
});
}
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
{
TenantId = config.TenantId,
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
EventKindOverrides = config.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
?? new Dictionary<string, int>(),
MaxEventsPerWindow = config.MaxEventsPerWindow,
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
? (int)config.BurstWindowDuration.Value.TotalSeconds
: null,
Enabled = config.Enabled,
CreatedAt = config.CreatedAt,
CreatedBy = config.CreatedBy,
UpdatedAt = config.UpdatedAt,
UpdatedBy = config.UpdatedBy,
IsDefault = false
};
}
#region API Request/Response Models
/// <summary>
/// Request to create or update throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiRequest
{
public string? TenantId { get; set; }
public int? DefaultDurationSeconds { get; set; }
public Dictionary<string, int>? EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate throttle duration.
/// </summary>
public sealed class ThrottleEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
}
/// <summary>
/// Response for throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiResponse
{
public required string TenantId { get; set; }
public required int DefaultDurationSeconds { get; set; }
public required Dictionary<string, int> EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public required bool Enabled { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
public bool IsDefault { get; set; }
}
/// <summary>
/// Response for throttle evaluation.
/// </summary>
public sealed class ThrottleEvaluateApiResponse
{
public required string EventKind { get; set; }
public required int EffectiveDurationSeconds { get; set; }
}
#endregion

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Routing;
namespace StellaOps.Notify.WebService.Extensions;
/// <summary>
/// Minimal no-op OpenAPI extension to preserve existing endpoint grouping without external dependencies.
/// </summary>
public static class OpenApiExtensions
{
public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder)
where TBuilder : IEndpointConventionBuilder => builder;
}

View File

@@ -21,6 +21,8 @@ using StellaOps.Notify.Persistence.Extensions;
using StellaOps.Notify.Persistence.Postgres;
using StellaOps.Notify.Persistence.Postgres.Models;
using StellaOps.Notify.Persistence.Postgres.Repositories;
// Alias to disambiguate from StellaOps.Notifier.Worker.Storage.INotifyAuditRepository
using INotifyAuditRepository = StellaOps.Notify.Persistence.Postgres.Repositories.INotifyAuditRepository;
using StellaOps.Notify.WebService.Contracts;
using StellaOps.Notify.WebService.Diagnostics;
using StellaOps.Notify.WebService.Extensions;
@@ -32,6 +34,27 @@ using StellaOps.Notify.WebService.Security;
using StellaOps.Notify.WebService.Services;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Router.AspNet;
// Notifier Worker shared types (correlation, simulation, security, escalation, etc.)
using StellaOps.Cryptography;
using StellaOps.Auth.Abstractions;
using StellaOps.Notify.Queue;
using StellaOps.Notify.WebService.Constants;
using StellaOps.Notify.WebService.Endpoints;
using StellaOps.Notify.WebService.Setup;
using StellaOps.Notify.WebService.Storage.Compat;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notifier.Worker.Security;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.Worker.DeadLetter;
using StellaOps.Notifier.Worker.Retention;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.Worker.Tenancy;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.Worker.Simulation;
using StellaOps.Notifier.Worker.Storage;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
using System.Collections.Immutable;
using System.Diagnostics;
@@ -102,6 +125,104 @@ builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
// =========================================================================
// Notifier v2 DI registrations (merged from Notifier WebService)
// =========================================================================
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
// Core correlation engine registrations required by incident and escalation flows.
builder.Services.AddCorrelationServices(builder.Configuration);
// Rule evaluation + simulation services power /api/v2/simulate* endpoints.
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
// In-memory storage for Notifier v2 endpoints (fully qualified to avoid ambiguity with Notify.Persistence types)
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyChannelRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyRuleRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyTemplateRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyDeliveryRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyAuditRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLockRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyInboxRepository, InMemoryInboxStore>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLocalizationRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyPackApprovalRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryPackApprovalRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyThrottleConfigRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOperatorOverrideRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOperatorOverrideRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyQuietHoursRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryQuietHoursRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyMaintenanceWindowRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryMaintenanceWindowRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyEscalationPolicyRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryEscalationPolicyRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOnCallScheduleRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOnCallScheduleRepository>();
// Correlation suppression services
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
// Template service with enhanced renderer (worker contracts)
builder.Services.AddTemplateServices(options =>
{
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
if (!string.IsNullOrWhiteSpace(provenanceUrl))
{
options.ProvenanceBaseUrl = provenanceUrl;
}
});
// Localization resolver with fallback chain
builder.Services.AddSingleton<StellaOps.Notify.Models.ILocalizationResolver, StellaOps.Notify.WebService.Services.DefaultLocalizationResolver>();
// Security services (ack tokens, webhook, HTML sanitizer, tenant isolation)
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
// Observability, dead-letter, and retention services
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
// Escalation and on-call services
builder.Services.AddEscalationServices(builder.Configuration);
// Storm breaker services
builder.Services.AddStormBreakerServices(builder.Configuration);
// Additional security services (signing, webhook validation)
builder.Services.AddNotifierSecurityServices(builder.Configuration);
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
// Notifier WebService template/renderer services
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateService, StellaOps.Notify.WebService.Services.NotifyTemplateService>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateRenderer, StellaOps.Notify.WebService.Services.AdvancedTemplateRenderer>();
// Notifier authorization policies
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyViewer, StellaOpsScopes.NotifyViewer);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyOperator, StellaOpsScopes.NotifyOperator);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyAdmin, StellaOpsScopes.NotifyAdmin);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyEscalate, StellaOpsScopes.NotifyEscalate);
});
builder.Services.AddHealthChecks();
// =========================================================================
ConfigureAuthentication(builder, bootstrapOptions, builder.Configuration);
ConfigureRateLimiting(builder, bootstrapOptions);
@@ -129,8 +250,38 @@ var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceO
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
ConfigureRequestPipeline(app, bootstrapOptions, routerEnabled);
// Enable WebSocket support for live incident feed (merged from Notifier)
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
// Tenant context middleware (from Notifier merge)
app.UseTenantContext();
ConfigureEndpoints(app);
// =========================================================================
// Notifier v2 endpoint mappings (merged from Notifier WebService)
// =========================================================================
app.MapNotifyApiV2();
app.MapRuleEndpoints();
app.MapTemplateEndpoints();
app.MapIncidentEndpoints();
app.MapIncidentLiveFeed();
app.MapSimulationEndpoints();
app.MapQuietHoursEndpoints();
app.MapThrottleEndpoints();
app.MapOperatorOverrideEndpoints();
app.MapEscalationEndpoints();
app.MapStormBreakerEndpoints();
app.MapLocalizationEndpoints();
app.MapFallbackEndpoints();
app.MapSecurityEndpoints();
app.MapObservabilityEndpoints();
// =========================================================================
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);

View File

@@ -0,0 +1,349 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
namespace StellaOps.Notify.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}

View File

@@ -0,0 +1,202 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Models;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}

View File

@@ -0,0 +1,103 @@
using StellaOps.Notify.Models;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}

View File

@@ -0,0 +1,274 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,257 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.WebService.Setup;
/// <summary>
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class AttestationTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<AttestationTemplateSeeder> _logger;
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping attestation template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default attestation routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateAttestationTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateAttestationRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
using var stream = File.OpenRead(samplePath);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(el => ToChannel(el, tenant))
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.GetProperty("enabled").GetBoolean();
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded attestation routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: tenantOverride,
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:attestation");
}
private static string? LocateAttestationTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateAttestationRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,22 @@
using StellaOps.Notify.Queue;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.WebService.Setup;
/// <summary>
/// No-op event queue used when a real queue backend is not configured (dev/test/offline).
/// </summary>
public sealed class NullNotifyEventQueue : INotifyEventQueue
{
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new NotifyQueueEnqueueResult("null", false));
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}

View File

@@ -0,0 +1,38 @@
using System.Text;
namespace StellaOps.Notify.WebService.Setup;
public sealed class OpenApiDocumentCache
{
private readonly string _document;
private readonly string _hash;
public OpenApiDocumentCache(IHostEnvironment environment)
{
var candidateRoots = new[]
{
Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml"),
Path.Combine(environment.ContentRootPath, "TestContent", "openapi", "notify-openapi.yaml"),
Path.Combine(AppContext.BaseDirectory, "openapi", "notify-openapi.yaml")
};
var path = candidateRoots.FirstOrDefault(File.Exists);
if (path is null)
{
_document = "# notifier openapi (stub for tests)\nopenapi: 3.1.0\ninfo:\n title: stub\n version: 0.0.0\npaths: {}\n";
_hash = "stub-openapi";
return;
}
_document = File.ReadAllText(path, Encoding.UTF8);
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
public string Document => _document;
public string Sha256 => _hash;
}

View File

@@ -0,0 +1,234 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.WebService.Setup;
/// <summary>
/// Seeds pack-approval templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class PackApprovalTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<PackApprovalTemplateSeeder> _logger;
public PackApprovalTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<PackApprovalTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping pack-approval template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var seeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (seeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} pack-approval templates from docs.", seeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping pack-approval routing seed.");
return;
}
var routed = await SeedRoutingAsync(channelRepo, ruleRepo, _logger, cancellationToken).ConfigureAwait(false);
if (routed > 0)
{
_logger.LogInformation("Seeded default pack-approval routing (channels + rule).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var path = LocateTemplatesPath(contentRootPath);
if (path is null)
{
logger.LogWarning("pack-approval-templates.json not found under content root {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
using var stream = File.OpenRead(path);
using var document = JsonDocument.Parse(stream);
if (!document.RootElement.TryGetProperty("templates", out var templatesElement))
{
logger.LogWarning("pack-approval-templates.json missing 'templates' array; skipping seed.");
return 0;
}
var count = 0;
foreach (var template in templatesElement.EnumerateArray())
{
try
{
var model = ToTemplate(template);
await repository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template entry; skipping.");
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
const string tenant = "tenant-sample";
var slackChannel = NotifyChannel.Create(
channelId: "chn-pack-approvals-slack",
tenantId: tenant,
name: "Slack · Pack Approvals",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/pack-approvals",
endpoint: "https://hooks.slack.local/services/T000/B000/DEV",
target: "#pack-approvals"),
description: "Default Slack channel for pack approval notifications.");
var emailChannel = NotifyChannel.Create(
channelId: "chn-pack-approvals-email",
tenantId: tenant,
name: "Email · Pack Approvals",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/pack-approvals",
target: "pack-approvals@example.com"),
description: "Default email channel for pack approval notifications.");
await channelRepository.UpsertAsync(slackChannel, cancellationToken).ConfigureAwait(false);
await channelRepository.UpsertAsync(emailChannel, cancellationToken).ConfigureAwait(false);
var rule = NotifyRule.Create(
ruleId: "rule-pack-approvals-default",
tenantId: tenant,
name: "Pack approvals → Slack + Email",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "pack.approval.granted", "pack.approval.denied", "pack.policy.override" },
labels: new[] { "environment=prod" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-pack-approvals-slack",
channel: slackChannel.ChannelId,
template: "tmpl-pack-approval-slack-en",
locale: "en-US"),
NotifyRuleAction.Create(
actionId: "act-pack-approvals-email",
channel: emailChannel.ChannelId,
template: "tmpl-pack-approval-email-en",
locale: "en-US")
},
description: "Routes pack approval events to seeded Slack and Email channels.");
await ruleRepository.UpsertAsync(rule, cancellationToken).ConfigureAwait(false);
return 3; // two channels + one rule
}
private static string? LocateTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "pack-approval-templates.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
Path.Combine(contentRootPath, "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
Path.Combine(contentRootPath, "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "pack-approval-templates.json")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return Path.GetFullPath(candidate);
}
}
return null;
}
private static NotifyTemplate ToTemplate(JsonElement element)
{
var templateId = element.GetProperty("templateId").GetString() ?? throw new InvalidOperationException("templateId missing");
var tenantId = element.GetProperty("tenantId").GetString() ?? throw new InvalidOperationException("tenantId missing");
var key = element.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = element.GetProperty("locale").GetString() ?? "en-US";
var body = element.GetProperty("body").GetString() ?? string.Empty;
var channelType = ParseEnum<NotifyChannelType>(element.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var renderMode = ParseEnum<NotifyTemplateRenderMode>(element.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(element.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = element.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var metadata = element.TryGetProperty("metadata", out var meta)
? meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty))
: Enumerable.Empty<KeyValuePair<string, string>>();
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:pack-approvals");
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,259 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Xml;
namespace StellaOps.Notify.WebService.Setup;
/// <summary>
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class RiskTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<RiskTemplateSeeder> _logger;
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping risk template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default risk routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateRiskTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateRiskRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
await using var stream = File.OpenRead(samplePath);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(el => ToChannel(el, tenant))
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true;
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded risk routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: tenantOverride,
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:risk");
}
private static string? LocateRiskTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateRiskRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Notify.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;

View File

@@ -8,6 +8,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
@@ -24,12 +28,16 @@
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
<!-- Notifier Worker: shared types for correlation, simulation, security, escalation, etc. -->
<ProjectReference Include="../../Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />

View File

@@ -0,0 +1,76 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyEscalationPolicyRepository
{
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
string? policyType,
CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy> UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyEscalationPolicy>> _store = new();
public Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
string? policyType,
CancellationToken cancellationToken = default)
{
var result = ForTenant(tenantId).Values
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicy>>(result);
}
public Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(policyId, out var policy);
return Task.FromResult(policy);
}
public Task<NotifyEscalationPolicy> UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default)
{
var items = ForTenant(policy.TenantId);
items[policy.PolicyId] = policy;
return Task.FromResult(policy);
}
public Task<bool> DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(policyId, out _));
}
private ConcurrentDictionary<string, NotifyEscalationPolicy> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyEscalationPolicy>());
}

View File

@@ -0,0 +1,86 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyMaintenanceWindowRepository
{
Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyMaintenanceWindow>> _store = new();
public Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (activeOnly is true)
{
items = items.Where(w => w.IsActiveAt(now));
}
var result = items
.OrderBy(w => w.StartsAt)
.ThenBy(w => w.WindowId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(result);
}
public Task<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(windowId, out var window);
return Task.FromResult(window);
}
public Task<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default)
{
var items = ForTenant(window.TenantId);
items[window.WindowId] = window;
return Task.FromResult(window);
}
public Task<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(windowId, out _));
}
private ConcurrentDictionary<string, NotifyMaintenanceWindow> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyMaintenanceWindow>());
}

View File

@@ -0,0 +1,167 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyOnCallScheduleRepository
{
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? includeInactive,
CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule> UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride @override,
CancellationToken cancellationToken = default);
Task<bool> RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOnCallSchedule>> _store = new();
public Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? includeInactive,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (includeInactive is not true)
{
var now = DateTimeOffset.UtcNow;
items = items.Where(s => s.Overrides.Any(o => o.IsActiveAt(now)) || !s.Overrides.Any());
}
var result = items
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallSchedule>>(result);
}
public Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(scheduleId, out var schedule);
return Task.FromResult(schedule);
}
public Task<NotifyOnCallSchedule> UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default)
{
var items = ForTenant(schedule.TenantId);
items[schedule.ScheduleId] = schedule;
return Task.FromResult(schedule);
}
public Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(scheduleId, out _));
}
public Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride @override,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
if (!items.TryGetValue(scheduleId, out var schedule))
{
throw new KeyNotFoundException($"On-call schedule '{scheduleId}' not found.");
}
var updatedOverrides = schedule.Overrides.IsDefaultOrEmpty
? ImmutableArray.Create(@override)
: schedule.Overrides.Add(@override);
var updatedSchedule = NotifyOnCallSchedule.Create(
schedule.ScheduleId,
schedule.TenantId,
schedule.Name,
schedule.TimeZone,
schedule.Layers,
updatedOverrides,
schedule.Enabled,
schedule.Description,
schedule.Metadata,
schedule.CreatedBy,
schedule.CreatedAt,
schedule.UpdatedBy,
DateTimeOffset.UtcNow);
items[scheduleId] = updatedSchedule;
return Task.CompletedTask;
}
public Task<bool> RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
if (!items.TryGetValue(scheduleId, out var schedule))
{
return Task.FromResult(false);
}
var updatedOverrides = schedule.Overrides
.Where(o => !string.Equals(o.OverrideId, overrideId, StringComparison.Ordinal))
.ToImmutableArray();
var updatedSchedule = NotifyOnCallSchedule.Create(
schedule.ScheduleId,
schedule.TenantId,
schedule.Name,
schedule.TimeZone,
schedule.Layers,
updatedOverrides,
schedule.Enabled,
schedule.Description,
schedule.Metadata,
schedule.CreatedBy,
schedule.CreatedAt,
schedule.UpdatedBy,
DateTimeOffset.UtcNow);
items[scheduleId] = updatedSchedule;
return Task.FromResult(!schedule.Overrides.SequenceEqual(updatedOverrides));
}
private ConcurrentDictionary<string, NotifyOnCallSchedule> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOnCallSchedule>());
}

View File

@@ -0,0 +1,52 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyOperatorOverrideRepository
{
Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default);
Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
}
public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOperatorOverride>> _store = new();
public Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (activeOnly == true)
{
items = items.Where(o => o.ExpiresAt > now);
}
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(items.OrderBy(o => o.ExpiresAt).ToList());
}
public Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(overrideId, out var result);
return Task.FromResult(result);
}
public Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
{
var items = ForTenant(@override.TenantId);
items[@override.OverrideId] = @override;
return Task.FromResult(@override);
}
public Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(overrideId, out _));
}
private ConcurrentDictionary<string, NotifyOperatorOverride> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOperatorOverride>());
}

View File

@@ -0,0 +1,39 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyPackApprovalRepository
{
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
}
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _store = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_store[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
}
public sealed class PackApprovalDocument
{
public required string TenantId { get; init; }
public required Guid EventId { get; init; }
public required string PackId { get; init; }
public required string Kind { get; init; }
public required string Decision { get; init; }
public required string Actor { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public string? PolicyId { get; init; }
public string? PolicyVersion { get; init; }
public string? ResumeToken { get; init; }
public string? Summary { get; init; }
public IDictionary<string, string>? Labels { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,91 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyQuietHoursRepository
{
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default);
Task<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyQuietHoursSchedule>> _store = new();
public Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (!string.IsNullOrWhiteSpace(channelId))
{
items = items.Where(s =>
string.Equals(s.ChannelId, channelId, StringComparison.OrdinalIgnoreCase));
}
if (enabledOnly is true)
{
items = items.Where(s => s.Enabled);
}
var result = items
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(result);
}
public Task<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(scheduleId, out var schedule);
return Task.FromResult(schedule);
}
public Task<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default)
{
var items = ForTenant(schedule.TenantId);
items[schedule.ScheduleId] = schedule;
return Task.FromResult(schedule);
}
public Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(scheduleId, out _));
}
private ConcurrentDictionary<string, NotifyQuietHoursSchedule> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyQuietHoursSchedule>());
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Notify.Models;
using System.Collections.Concurrent;
namespace StellaOps.Notify.WebService.Storage.Compat;
public interface INotifyThrottleConfigRepository
{
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
}
public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyThrottleConfig>> _store = new();
public Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyThrottleConfig>>(items);
}
public Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(configId, out var config);
return Task.FromResult(config);
}
public Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
var items = ForTenant(config.TenantId);
items[config.ConfigId] = config;
return Task.FromResult(config);
}
public Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(configId, out _));
}
private ConcurrentDictionary<string, NotifyThrottleConfig> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyThrottleConfig>());
}

View File

@@ -1,15 +0,0 @@
# StellaOps.Notify.Worker — Agent Charter
## Mission
Consume events, evaluate rules, and dispatch deliveries per `docs/modules/notify/ARCHITECTURE.md`.
## Required Reading
- `docs/modules/notify/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -1,11 +0,0 @@
using StellaOps.Notify.Queue;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Worker.Handlers;
public interface INotifyEventHandler
{
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
}

View File

@@ -1,26 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Worker.Handlers;
internal sealed class NoOpNotifyEventHandler : INotifyEventHandler
{
private readonly ILogger<NoOpNotifyEventHandler> _logger;
public NoOpNotifyEventHandler(ILogger<NoOpNotifyEventHandler> logger)
{
_logger = logger;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
_logger.LogDebug(
"No-op handler acknowledged event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
return Task.CompletedTask;
}
}

View File

@@ -1,52 +0,0 @@
using System;
namespace StellaOps.Notify.Worker;
public sealed class NotifyWorkerOptions
{
/// <summary>
/// Worker identifier prefix; defaults to machine name.
/// </summary>
public string? WorkerId { get; set; }
/// <summary>
/// Number of messages to lease per iteration.
/// </summary>
public int LeaseBatchSize { get; set; } = 16;
/// <summary>
/// Duration a lease remains active before it becomes eligible for claim.
/// </summary>
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Delay applied when no work is available.
/// </summary>
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Maximum number of event leases processed concurrently.
/// </summary>
public int MaxConcurrency { get; set; } = 4;
/// <summary>
/// Maximum number of consecutive failures before the worker delays.
/// </summary>
public int FailureBackoffThreshold { get; set; } = 3;
/// <summary>
/// Delay applied when the failure threshold is reached.
/// </summary>
public TimeSpan FailureBackoffDelay { get; set; } = TimeSpan.FromSeconds(5);
internal string ResolveWorkerId(Guid? fallbackGuid = null)
{
if (!string.IsNullOrWhiteSpace(WorkerId))
{
return WorkerId!;
}
var host = Environment.MachineName;
return $"{host}-{(fallbackGuid ?? Guid.NewGuid()):n}";
}
}

View File

@@ -1,147 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker.Handlers;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseProcessor
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Notify.Worker");
private readonly INotifyEventQueue _queue;
private readonly INotifyEventHandler _handler;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _workerId;
public NotifyEventLeaseProcessor(
INotifyEventQueue queue,
INotifyEventHandler handler,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseProcessor> logger,
TimeProvider timeProvider)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_workerId = _options.ResolveWorkerId();
}
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var leaseRequest = new NotifyQueueLeaseRequest(
consumer: _workerId,
batchSize: Math.Max(1, _options.LeaseBatchSize),
leaseDuration: _options.LeaseDuration <= TimeSpan.Zero ? TimeSpan.FromSeconds(30) : _options.LeaseDuration);
IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>> leases;
try
{
leases = await _queue.LeaseAsync(leaseRequest, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to lease Notify events.");
throw;
}
if (leases.Count == 0)
{
return 0;
}
var processed = 0;
foreach (var lease in leases)
{
cancellationToken.ThrowIfCancellationRequested();
processed++;
await ProcessLeaseAsync(lease, cancellationToken).ConfigureAwait(false);
}
return processed;
}
private async Task ProcessLeaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
CancellationToken cancellationToken)
{
var message = lease.Message;
var correlationId = message.TraceId ?? message.Event.EventId.ToString("N");
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["notifyTraceId"] = correlationId,
["notifyTenantId"] = message.TenantId,
["notifyEventId"] = message.Event.EventId,
["notifyAttempt"] = lease.Attempt
});
using var activity = ActivitySource.StartActivity("notify.event.process", ActivityKind.Consumer);
activity?.SetTag("notify.tenant_id", message.TenantId);
activity?.SetTag("notify.event_id", message.Event.EventId);
activity?.SetTag("notify.attempt", lease.Attempt);
activity?.SetTag("notify.worker_id", _workerId);
try
{
_logger.LogInformation(
"Processing notify event {EventId} (tenant {TenantId}, attempt {Attempt}).",
message.Event.EventId,
message.TenantId,
lease.Attempt);
await _handler.HandleAsync(message, cancellationToken).ConfigureAwait(false);
await lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Acknowledged notify event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Worker cancellation requested while processing event {EventId}; returning lease to queue.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, CancellationToken.None).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process notify event {EventId}; scheduling retry.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
}
}
private static async Task SafeReleaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
try
{
await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false);
}
catch when (cancellationToken.IsCancellationRequested)
{
// Suppress release errors during shutdown.
}
}
}

View File

@@ -1,64 +0,0 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseWorker : BackgroundService
{
private readonly NotifyEventLeaseProcessor _processor;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseWorker> _logger;
public NotifyEventLeaseWorker(
NotifyEventLeaseProcessor processor,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseWorker> logger)
{
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var idleDelay = _options.IdleDelay <= TimeSpan.Zero
? TimeSpan.FromMilliseconds(500)
: _options.IdleDelay;
while (!stoppingToken.IsCancellationRequested)
{
int processed;
try
{
processed = await _processor.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify worker processing loop encountered an error.");
await Task.Delay(_options.FailureBackoffDelay, stoppingToken).ConfigureAwait(false);
continue;
}
if (processed == 0)
{
try
{
await Task.Delay(idleDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
}

View File

@@ -1,38 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
using StellaOps.Worker.Health;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "NOTIFY_");
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
options.UseUtcTimestamp = true;
});
builder.Services.Configure<NotifyWorkerOptions>(builder.Configuration.GetSection("notify:worker"));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddNotifyEventQueue(builder.Configuration, "notify:queue");
builder.Services.AddNotifyDeliveryQueue(builder.Configuration, "notify:deliveryQueue");
builder.Services.AddSingleton<INotifyEventHandler, NoOpNotifyEventHandler>();
builder.Services.AddSingleton<NotifyEventLeaseProcessor>();
builder.Services.AddHostedService<NotifyEventLeaseWorker>();
builder.Services.AddWorkerHealthChecks();
var app = builder.Build();
app.MapWorkerHealthEndpoints();
await app.RunAsync().ConfigureAwait(false);

View File

@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- FrameworkReference Microsoft.AspNetCore.App and Hosting packages are provided by Sdk.Web -->
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,11 +0,0 @@
# StellaOps.Notify.Worker Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0418-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Notify.Worker. |
| AUDIT-0418-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Notify.Worker. |
| AUDIT-0418-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,43 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"notify": {
"worker": {
"leaseBatchSize": 16,
"leaseDuration": "00:00:30",
"idleDelay": "00:00:00.250",
"maxConcurrency": 4,
"failureBackoffThreshold": 3,
"failureBackoffDelay": "00:00:05"
},
"queue": {
"transport": "Redis",
"redis": {
"connectionString": "localhost:6379",
"streams": [
{
"stream": "notify:events",
"consumerGroup": "notify-workers",
"idempotencyKeyPrefix": "notify:events:idemp:",
"approximateMaxLength": 100000
}
]
}
},
"deliveryQueue": {
"transport": "Redis",
"redis": {
"connectionString": "localhost:6379",
"streamName": "notify:deliveries",
"consumerGroup": "notify-delivery",
"idempotencyKeyPrefix": "notify:deliveries:idemp:",
"deadLetterStreamName": "notify:deliveries:dead"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -101,8 +101,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/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
@@ -115,8 +115,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": "http://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
{ "Type": "Microservice", "Path": "^/api/v1/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/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
@@ -134,7 +134,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://jobengine.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" },
@@ -152,9 +152,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://jobengine.stella-ops.local/v1/runs$1" },