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

"); + html.Replace("\n## ", "\n

"); + html.Replace("\n# ", "\n

"); + + // Bold + html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "$1")); + + // Italic + html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "$1")); + + // Code + html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "$1")); + + // Links + html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "$1")); + + // Line breaks + html.Replace("\n\n", "

"); + html.Replace("\n", "
"); + + return $"

{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}

Template: {template.Key}

", + 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(); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Services/DefaultLocalizationResolver.cs b/src/Notify/StellaOps.Notify.WebService/Services/DefaultLocalizationResolver.cs new file mode 100644 index 000000000..b81147a7d --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Services/DefaultLocalizationResolver.cs @@ -0,0 +1,202 @@ + +using Microsoft.Extensions.Logging; +using StellaOps.Notifier.Worker.Storage; +using StellaOps.Notify.Models; + +namespace StellaOps.Notify.WebService.Services; + +/// +/// Default implementation of ILocalizationResolver with hierarchical fallback chain. +/// +public sealed class DefaultLocalizationResolver : ILocalizationResolver +{ + private const string DefaultLocale = "en-us"; + private const string DefaultLanguage = "en"; + + private readonly INotifyLocalizationRepository _repository; + private readonly ILogger _logger; + + public DefaultLocalizationResolver( + INotifyLocalizationRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task 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> ResolveBatchAsync( + string tenantId, + string bundleKey, + IEnumerable 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(stringKeys, StringComparer.Ordinal); + var results = new Dictionary(StringComparer.Ordinal); + + // Load all bundles in the fallback chain + var bundles = new List(); + 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; + } + + /// + /// Builds a fallback chain for the given locale. + /// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"] + /// + private static IReadOnlyList BuildFallbackChain(string locale) + { + var chain = new List { 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(); + } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateRenderer.cs b/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateRenderer.cs new file mode 100644 index 000000000..7d92aafab --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateRenderer.cs @@ -0,0 +1,16 @@ + +using StellaOps.Notify.Models; +using System.Text.Json.Nodes; + +namespace StellaOps.Notify.WebService.Services; + +/// +/// Template renderer with support for render options, format conversion, and redaction. +/// +public interface INotifyTemplateRenderer +{ + /// + /// Renders a template with the given payload and options. + /// + string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateService.cs b/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateService.cs new file mode 100644 index 000000000..a56b92823 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Services/INotifyTemplateService.cs @@ -0,0 +1,103 @@ + +using StellaOps.Notify.Models; +using System.Text.Json.Nodes; + +namespace StellaOps.Notify.WebService.Services; + +/// +/// Application-level service for managing versioned templates with localization support. +/// +public interface INotifyTemplateService +{ + /// + /// Gets a template by key and locale, falling back to the default locale if not found. + /// + Task GetByKeyAsync( + string tenantId, + string key, + string locale, + NotifyChannelType? channelType = null, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific template by ID. + /// + Task GetByIdAsync( + string tenantId, + string templateId, + CancellationToken cancellationToken = default); + + /// + /// Lists all templates for a tenant, optionally filtered. + /// + Task> ListAsync( + string tenantId, + string? keyPrefix = null, + string? locale = null, + NotifyChannelType? channelType = null, + CancellationToken cancellationToken = default); + + /// + /// Creates or updates a template with version tracking. + /// + Task UpsertAsync( + NotifyTemplate template, + string updatedBy, + CancellationToken cancellationToken = default); + + /// + /// Deletes a template. + /// + Task DeleteAsync( + string tenantId, + string templateId, + CancellationToken cancellationToken = default); + + /// + /// Renders a template preview with sample payload (no persistence). + /// + Task PreviewAsync( + NotifyTemplate template, + JsonNode? samplePayload, + TemplateRenderOptions? options = null, + CancellationToken cancellationToken = default); +} + +/// +/// Result of a template preview render. +/// +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 RedactedFields { get; init; } = []; + public string? ProvenanceLink { get; init; } +} + +/// +/// Options for template rendering. +/// +public sealed record TemplateRenderOptions +{ + /// + /// Fields to redact from the output (dot-notation paths). + /// + public IReadOnlySet? RedactionAllowlist { get; init; } + + /// + /// Whether to include provenance links in output. + /// + public bool IncludeProvenance { get; init; } = true; + + /// + /// Base URL for provenance links. + /// + public string? ProvenanceBaseUrl { get; init; } + + /// + /// Target format override. + /// + public NotifyDeliveryFormat? FormatOverride { get; init; } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Services/NotifyTemplateService.cs b/src/Notify/StellaOps.Notify.WebService/Services/NotifyTemplateService.cs new file mode 100644 index 000000000..057f11034 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Services/NotifyTemplateService.cs @@ -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; + +/// +/// Default implementation of INotifyTemplateService with locale fallback and version tracking. +/// +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 _logger; + + public NotifyTemplateService( + INotifyTemplateRepository repository, + INotifyTemplateRenderer renderer, + TimeProvider timeProvider, + ILogger 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 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 GetByIdAsync( + string tenantId, + string templateId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(templateId); + + return _repository.GetAsync(tenantId, templateId, cancellationToken); + } + + public async Task> 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 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 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 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(); + 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 allowlist, List 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(); + } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/AttestationTemplateSeeder.cs b/src/Notify/StellaOps.Notify.WebService/Setup/AttestationTemplateSeeder.cs new file mode 100644 index 000000000..db4119290 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/AttestationTemplateSeeder.cs @@ -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; + +/// +/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios. +/// +public sealed class AttestationTemplateSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + + public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger 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(); + var channelRepo = scope.ServiceProvider.GetService(); + var ruleRepo = scope.ServiceProvider.GetService(); + + 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 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 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(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 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(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(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown); + var format = ParseEnum(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>(); + if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object) + { + metadata = meta.EnumerateObject().Select(p => new KeyValuePair(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(string? value, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/NullNotifyEventQueue.cs b/src/Notify/StellaOps.Notify.WebService/Setup/NullNotifyEventQueue.cs new file mode 100644 index 000000000..5575c4da2 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/NullNotifyEventQueue.cs @@ -0,0 +1,22 @@ + +using StellaOps.Notify.Queue; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Notify.WebService.Setup; + +/// +/// No-op event queue used when a real queue backend is not configured (dev/test/offline). +/// +public sealed class NullNotifyEventQueue : INotifyEventQueue +{ + public ValueTask PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default) => + ValueTask.FromResult(new NotifyQueueEnqueueResult("null", false)); + + public ValueTask>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default) => + ValueTask.FromResult>>(Array.Empty>()); + + public ValueTask>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default) => + ValueTask.FromResult>>(Array.Empty>()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/OpenApiDocumentCache.cs b/src/Notify/StellaOps.Notify.WebService/Setup/OpenApiDocumentCache.cs new file mode 100644 index 000000000..b5c8fd32d --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/OpenApiDocumentCache.cs @@ -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; +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/PackApprovalTemplateSeeder.cs b/src/Notify/StellaOps.Notify.WebService/Setup/PackApprovalTemplateSeeder.cs new file mode 100644 index 000000000..56b918ff1 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/PackApprovalTemplateSeeder.cs @@ -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; + +/// +/// Seeds pack-approval templates and default routing for dev/test/bootstrap scenarios. +/// +public sealed class PackApprovalTemplateSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + + public PackApprovalTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger 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(); + var channelRepo = scope.ServiceProvider.GetService(); + var ruleRepo = scope.ServiceProvider.GetService(); + + 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 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 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(element.GetProperty("channelType").GetString(), NotifyChannelType.Custom); + var renderMode = ParseEnum(element.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown); + var format = ParseEnum(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(p.Name, p.Value.GetString() ?? string.Empty)) + : Enumerable.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:pack-approvals"); + } + + private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/RiskTemplateSeeder.cs b/src/Notify/StellaOps.Notify.WebService/Setup/RiskTemplateSeeder.cs new file mode 100644 index 000000000..276fbe961 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/RiskTemplateSeeder.cs @@ -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; + +/// +/// Seeds risk templates and default routing for dev/test/bootstrap scenarios. +/// +public sealed class RiskTemplateSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + + public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger 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(); + var channelRepo = scope.ServiceProvider.GetService(); + var ruleRepo = scope.ServiceProvider.GetService(); + + 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 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 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(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 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(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(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown); + var format = ParseEnum(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>(); + if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object) + { + metadata = meta.EnumerateObject().Select(p => new KeyValuePair(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(string? value, TEnum fallback) where TEnum : struct + { + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return fallback; + } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Setup/WebServiceAssemblyMarker.cs b/src/Notify/StellaOps.Notify.WebService/Setup/WebServiceAssemblyMarker.cs new file mode 100644 index 000000000..49a9ec969 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Setup/WebServiceAssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace StellaOps.Notify.WebService; + +/// +/// Marker type used for testing/hosting the web application. +/// +public sealed class WebServiceAssemblyMarker; diff --git a/src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj b/src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj index 8edff2950..3085ee05b 100644 --- a/src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj +++ b/src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj @@ -8,6 +8,10 @@ true + + false + + @@ -24,12 +28,16 @@ + + + + diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/EscalationPolicyCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/EscalationPolicyCompat.cs new file mode 100644 index 000000000..d7ea03880 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/EscalationPolicyCompat.cs @@ -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> ListAsync( + string tenantId, + string? policyType, + CancellationToken cancellationToken = default); + + Task GetAsync( + string tenantId, + string policyId, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + NotifyEscalationPolicy policy, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string tenantId, + string policyId, + CancellationToken cancellationToken = default); +} + +public sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> ListAsync( + string tenantId, + string? policyType, + CancellationToken cancellationToken = default) + { + var result = ForTenant(tenantId).Values + .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Task.FromResult>(result); + } + + public Task GetAsync( + string tenantId, + string policyId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(policyId, out var policy); + return Task.FromResult(policy); + } + + public Task UpsertAsync( + NotifyEscalationPolicy policy, + CancellationToken cancellationToken = default) + { + var items = ForTenant(policy.TenantId); + items[policy.PolicyId] = policy; + return Task.FromResult(policy); + } + + public Task DeleteAsync( + string tenantId, + string policyId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + return Task.FromResult(items.TryRemove(policyId, out _)); + } + + private ConcurrentDictionary ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/MaintenanceWindowCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/MaintenanceWindowCompat.cs new file mode 100644 index 000000000..5ae956e64 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/MaintenanceWindowCompat.cs @@ -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> ListAsync( + string tenantId, + bool? activeOnly, + DateTimeOffset now, + CancellationToken cancellationToken = default); + + Task GetAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + NotifyMaintenanceWindow window, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default); +} + +public sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> 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>(result); + } + + public Task GetAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(windowId, out var window); + return Task.FromResult(window); + } + + public Task UpsertAsync( + NotifyMaintenanceWindow window, + CancellationToken cancellationToken = default) + { + var items = ForTenant(window.TenantId); + items[window.WindowId] = window; + return Task.FromResult(window); + } + + public Task DeleteAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + return Task.FromResult(items.TryRemove(windowId, out _)); + } + + private ConcurrentDictionary ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OnCallScheduleCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OnCallScheduleCompat.cs new file mode 100644 index 000000000..ba80682b0 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OnCallScheduleCompat.cs @@ -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> ListAsync( + string tenantId, + bool? includeInactive, + CancellationToken cancellationToken = default); + + Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + NotifyOnCallSchedule schedule, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default); + + Task AddOverrideAsync( + string tenantId, + string scheduleId, + NotifyOnCallOverride @override, + CancellationToken cancellationToken = default); + + Task RemoveOverrideAsync( + string tenantId, + string scheduleId, + string overrideId, + CancellationToken cancellationToken = default); +} + +public sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> 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>(result); + } + + public Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(scheduleId, out var schedule); + return Task.FromResult(schedule); + } + + public Task UpsertAsync( + NotifyOnCallSchedule schedule, + CancellationToken cancellationToken = default) + { + var items = ForTenant(schedule.TenantId); + items[schedule.ScheduleId] = schedule; + return Task.FromResult(schedule); + } + + public Task 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 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 ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OperatorOverrideCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OperatorOverrideCompat.cs new file mode 100644 index 000000000..fd00917b0 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/OperatorOverrideCompat.cs @@ -0,0 +1,52 @@ + +using StellaOps.Notify.Models; +using System.Collections.Concurrent; + +namespace StellaOps.Notify.WebService.Storage.Compat; + +public interface INotifyOperatorOverrideRepository +{ + Task> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default); + Task GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default); + Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default); + Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default); +} + +public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> 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>(items.OrderBy(o => o.ExpiresAt).ToList()); + } + + public Task GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(overrideId, out var result); + return Task.FromResult(result); + } + + public Task UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default) + { + var items = ForTenant(@override.TenantId); + items[@override.OverrideId] = @override; + return Task.FromResult(@override); + } + + public Task DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + return Task.FromResult(items.TryRemove(overrideId, out _)); + } + + private ConcurrentDictionary ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/PackApprovalCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/PackApprovalCompat.cs new file mode 100644 index 000000000..9b1619540 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/PackApprovalCompat.cs @@ -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? Labels { get; init; } + public IDictionary? Metadata { get; init; } +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursCompat.cs new file mode 100644 index 000000000..86f043511 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursCompat.cs @@ -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> ListAsync( + string tenantId, + string? channelId, + bool? enabledOnly, + CancellationToken cancellationToken = default); + + Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default); + + Task UpsertAsync( + NotifyQuietHoursSchedule schedule, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default); +} + +public sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> 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>(result); + } + + public Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(scheduleId, out var schedule); + return Task.FromResult(schedule); + } + + public Task UpsertAsync( + NotifyQuietHoursSchedule schedule, + CancellationToken cancellationToken = default) + { + var items = ForTenant(schedule.TenantId); + items[schedule.ScheduleId] = schedule; + return Task.FromResult(schedule); + } + + public Task DeleteAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + return Task.FromResult(items.TryRemove(scheduleId, out _)); + } + + private ConcurrentDictionary ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/ThrottleConfigCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/ThrottleConfigCompat.cs new file mode 100644 index 000000000..463d99af7 --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/ThrottleConfigCompat.cs @@ -0,0 +1,49 @@ + +using StellaOps.Notify.Models; +using System.Collections.Concurrent; + +namespace StellaOps.Notify.WebService.Storage.Compat; + +public interface INotifyThrottleConfigRepository +{ + Task> ListAsync(string tenantId, CancellationToken cancellationToken = default); + Task GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default); + Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default); + Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default); +} + +public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository +{ + private readonly ConcurrentDictionary> _store = new(); + + public Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId).Values + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + return Task.FromResult>(items); + } + + public Task GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + items.TryGetValue(configId, out var config); + return Task.FromResult(config); + } + + public Task UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default) + { + var items = ForTenant(config.TenantId); + items[config.ConfigId] = config; + return Task.FromResult(config); + } + + public Task DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default) + { + var items = ForTenant(tenantId); + return Task.FromResult(items.TryRemove(configId, out _)); + } + + private ConcurrentDictionary ForTenant(string tenantId) => + _store.GetOrAdd(tenantId, _ => new ConcurrentDictionary()); +} diff --git a/src/Notify/StellaOps.Notify.Worker/AGENTS.md b/src/Notify/StellaOps.Notify.Worker/AGENTS.md deleted file mode 100644 index 40b83bfde..000000000 --- a/src/Notify/StellaOps.Notify.Worker/AGENTS.md +++ /dev/null @@ -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. diff --git a/src/Notify/StellaOps.Notify.Worker/Handlers/INotifyEventHandler.cs b/src/Notify/StellaOps.Notify.Worker/Handlers/INotifyEventHandler.cs deleted file mode 100644 index 96745a5d2..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Handlers/INotifyEventHandler.cs +++ /dev/null @@ -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); -} diff --git a/src/Notify/StellaOps.Notify.Worker/Handlers/NoOpNotifyEventHandler.cs b/src/Notify/StellaOps.Notify.Worker/Handlers/NoOpNotifyEventHandler.cs deleted file mode 100644 index bed584eb9..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Handlers/NoOpNotifyEventHandler.cs +++ /dev/null @@ -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 _logger; - - public NoOpNotifyEventHandler(ILogger 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; - } -} diff --git a/src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs b/src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs deleted file mode 100644 index 9f562941b..000000000 --- a/src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; - -namespace StellaOps.Notify.Worker; - -public sealed class NotifyWorkerOptions -{ - /// - /// Worker identifier prefix; defaults to machine name. - /// - public string? WorkerId { get; set; } - - /// - /// Number of messages to lease per iteration. - /// - public int LeaseBatchSize { get; set; } = 16; - - /// - /// Duration a lease remains active before it becomes eligible for claim. - /// - public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Delay applied when no work is available. - /// - public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250); - - /// - /// Maximum number of event leases processed concurrently. - /// - public int MaxConcurrency { get; set; } = 4; - - /// - /// Maximum number of consecutive failures before the worker delays. - /// - public int FailureBackoffThreshold { get; set; } = 3; - - /// - /// Delay applied when the failure threshold is reached. - /// - 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}"; - } -} diff --git a/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseProcessor.cs b/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseProcessor.cs deleted file mode 100644 index 8f18b8205..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseProcessor.cs +++ /dev/null @@ -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 _logger; - private readonly TimeProvider _timeProvider; - private readonly string _workerId; - - public NotifyEventLeaseProcessor( - INotifyEventQueue queue, - INotifyEventHandler handler, - IOptions options, - ILogger 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 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> 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 lease, - CancellationToken cancellationToken) - { - var message = lease.Message; - var correlationId = message.TraceId ?? message.Event.EventId.ToString("N"); - - using var scope = _logger.BeginScope(new Dictionary - { - ["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 lease, - NotifyQueueReleaseDisposition disposition, - CancellationToken cancellationToken) - { - try - { - await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false); - } - catch when (cancellationToken.IsCancellationRequested) - { - // Suppress release errors during shutdown. - } - } -} diff --git a/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseWorker.cs b/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseWorker.cs deleted file mode 100644 index 0ec06e129..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Processing/NotifyEventLeaseWorker.cs +++ /dev/null @@ -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 _logger; - - public NotifyEventLeaseWorker( - NotifyEventLeaseProcessor processor, - IOptions options, - ILogger 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; - } - } - } - } -} diff --git a/src/Notify/StellaOps.Notify.Worker/Program.cs b/src/Notify/StellaOps.Notify.Worker/Program.cs deleted file mode 100644 index a74e08a6c..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Program.cs +++ /dev/null @@ -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(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(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); - -builder.Services.AddWorkerHealthChecks(); - -var app = builder.Build(); -app.MapWorkerHealthEndpoints(); -await app.RunAsync().ConfigureAwait(false); diff --git a/src/Notify/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs b/src/Notify/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs deleted file mode 100644 index 7a46cf704..000000000 --- a/src/Notify/StellaOps.Notify.Worker/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")] diff --git a/src/Notify/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj b/src/Notify/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj deleted file mode 100644 index 53078393d..000000000 --- a/src/Notify/StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - net10.0 - enable - enable - true - - - - - - - - - - PreserveNewest - - - diff --git a/src/Notify/StellaOps.Notify.Worker/TASKS.md b/src/Notify/StellaOps.Notify.Worker/TASKS.md deleted file mode 100644 index ab13de730..000000000 --- a/src/Notify/StellaOps.Notify.Worker/TASKS.md +++ /dev/null @@ -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. | diff --git a/src/Notify/StellaOps.Notify.Worker/appsettings.json b/src/Notify/StellaOps.Notify.Worker/appsettings.json deleted file mode 100644 index 1be712f99..000000000 --- a/src/Notify/StellaOps.Notify.Worker/appsettings.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/src/Notify/StellaOps.Notify.sln b/src/Notify/StellaOps.Notify.sln index a0b8183d6..0b0e94135 100644 --- a/src/Notify/StellaOps.Notify.sln +++ b/src/Notify/StellaOps.Notify.sln @@ -1,572 +1,1121 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService", "{82766F65-4DE9-1353-CBF5-6E204D3035F8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Worker", "StellaOps.Notify.Worker", "{423298FC-947A-D3B2-1B79-C062E674B983}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AirGap", "AirGap", "{F310596E-88BB-9E54-885E-21C61971917E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{D9492ED1-A812-924B-65E4-F518592B49BB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{3823DE1E-2ACE-C956-99E1-00DB786D9E1D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Client", "StellaOps.Auth.Client", "{C494ECBE-DEA5-3576-D2AF-200FF12BC144}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Router", "Router", "{FC018E5B-1E2F-DE19-1E97-0C845058C469}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1BE5B76C-B486-560B-6CB2-44C6537249AA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice", "StellaOps.Microservice", "{3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.AspNetCore", "StellaOps.Microservice.AspNetCore", "{6FA01E92-606B-0CB8-8583-6F693A903CFC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.AspNet", "StellaOps.Router.AspNet", "{A5994E92-7E0E-89FE-5628-DE1A0176B8BA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common", "StellaOps.Router.Common", "{54C11B29-4C54-7255-AB44-BEB63AF9BD1F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Email", "StellaOps.Notify.Connectors.Email", "{E338BD91-C9B3-1381-0E6D-C7D6F3145D11}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Shared", "StellaOps.Notify.Connectors.Shared", "{40641C63-8727-2D4E-D16F-0AF601D7DCB4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Slack", "StellaOps.Notify.Connectors.Slack", "{BEDC7AEF-DD2F-5B51-C82D-6FE172266513}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Teams", "StellaOps.Notify.Connectors.Teams", "{E14EA762-C24A-4693-F9DA-A54957B72DFA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Webhook", "StellaOps.Notify.Connectors.Webhook", "{8EDDC9F9-2451-AB05-31C3-ABB48581A31C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Engine", "StellaOps.Notify.Engine", "{F6C88F41-9490-9667-CD1C-221CBE94A5C7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Models", "StellaOps.Notify.Models", "{6A82784D-6054-BBBC-5748-9DC29A2352EE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Persistence", "StellaOps.Notify.Persistence", "{8B550BAB-63D9-82E7-32E6-5380DC5E1414}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Queue", "StellaOps.Notify.Queue", "{68C1FD92-8269-2F2D-C47A-912AF1615116}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Storage.InMemory", "StellaOps.Notify.Storage.InMemory", "{FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Email.Tests", "StellaOps.Notify.Connectors.Email.Tests", "{BE909FA3-21B8-0909-9B5B-159834E28415}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Slack.Tests", "StellaOps.Notify.Connectors.Slack.Tests", "{56E267CB-08C2-7354-4345-7F14A889F768}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Teams.Tests", "StellaOps.Notify.Connectors.Teams.Tests", "{EFA84DBD-F726-77DB-8E21-109F91237278}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Webhook.Tests", "StellaOps.Notify.Connectors.Webhook.Tests", "{6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Core.Tests", "StellaOps.Notify.Core.Tests", "{48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Engine.Tests", "StellaOps.Notify.Engine.Tests", "{D0BD3E6B-D57E-9876-9991-8AB2F2EE095E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Models.Tests", "StellaOps.Notify.Models.Tests", "{1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Persistence.Tests", "StellaOps.Notify.Persistence.Tests", "{8D451E6C-7255-6BD0-BD0F-15D78B406B95}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Queue.Tests", "StellaOps.Notify.Queue.Tests", "{4F968D82-12A8-670A-2F16-AAE12A86F34A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.WebService.Tests", "StellaOps.Notify.WebService.Tests", "{4717E12D-C212-8CA0-4A94-CE6CA566B670}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Worker.Tests", "StellaOps.Notify.Worker.Tests", "{BB8833D5-6614-CEAD-39C0-760E86D5EFFA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "..\\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "..\\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "..\\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "..\\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "..\\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "..\\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "..\\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "..\\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "..\\Router\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "..\\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email", "__Libraries\StellaOps.Notify.Connectors.Email\StellaOps.Notify.Connectors.Email.csproj", "{1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email.Tests", "__Tests\StellaOps.Notify.Connectors.Email.Tests\StellaOps.Notify.Connectors.Email.Tests.csproj", "{1191C6F4-CDD4-D9B3-5723-59A17A1411C3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Shared", "__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj", "{B1AC2364-514D-CE6D-3387-9BFACF63C17C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack", "__Libraries\StellaOps.Notify.Connectors.Slack\StellaOps.Notify.Connectors.Slack.csproj", "{B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack.Tests", "__Tests\StellaOps.Notify.Connectors.Slack.Tests\StellaOps.Notify.Connectors.Slack.Tests.csproj", "{CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams", "__Libraries\StellaOps.Notify.Connectors.Teams\StellaOps.Notify.Connectors.Teams.csproj", "{0BA516C5-5B21-B0A8-60CF-00A4A744B46D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams.Tests", "__Tests\StellaOps.Notify.Connectors.Teams.Tests\StellaOps.Notify.Connectors.Teams.Tests.csproj", "{D1C7E5AC-931A-3084-6236-F3B2605DFC33}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook", "__Libraries\StellaOps.Notify.Connectors.Webhook\StellaOps.Notify.Connectors.Webhook.csproj", "{6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook.Tests", "__Tests\StellaOps.Notify.Connectors.Webhook.Tests\StellaOps.Notify.Connectors.Webhook.Tests.csproj", "{DCAEB360-E6CD-D87F-6750-6738A0C7534A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Core.Tests", "__Tests\StellaOps.Notify.Core.Tests\StellaOps.Notify.Core.Tests.csproj", "{09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{8ED04856-EACE-5385-CDFB-BBA78C545AA7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine.Tests", "__Tests\StellaOps.Notify.Engine.Tests\StellaOps.Notify.Engine.Tests.csproj", "{DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{20D1569C-2A47-38B8-075E-47225B674394}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tests", "__Tests\StellaOps.Notify.Models.Tests\StellaOps.Notify.Models.Tests.csproj", "{FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Persistence", "__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj", "{2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Persistence.Tests", "__Tests\StellaOps.Notify.Persistence.Tests\StellaOps.Notify.Persistence.Tests.csproj", "{467044CF-485E-3FAC-ABB8-DDB13A61D62F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{6A93F807-4839-1633-8B24-810660BB4C28}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.InMemory", "__Libraries\StellaOps.Notify.Storage.InMemory\StellaOps.Notify.Storage.InMemory.csproj", "{5634B7CF-C0A3-96C9-21FA-4090705F71BD}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService\StellaOps.Notify.WebService.csproj", "{B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{121E7D7D-F374-DE95-423B-2BDDDE91D063}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker", "StellaOps.Notify.Worker\StellaOps.Notify.Worker.csproj", "{7F71BC11-72B7-7FA6-ADF2-A9FEB112173B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{CF56A612-A1A4-4C27-1CFD-9F69423B91A8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "..\\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "..\\Router\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "..\\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.Build.0 = Release|Any CPU - {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.Build.0 = Debug|Any CPU - {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.ActiveCfg = Release|Any CPU - {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.Build.0 = Release|Any CPU - {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.Build.0 = Release|Any CPU - {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.Build.0 = Release|Any CPU - {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.Build.0 = Release|Any CPU - {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU - {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.Build.0 = Debug|Any CPU - {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU - {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU - {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.Build.0 = Release|Any CPU - {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.Build.0 = Release|Any CPU - {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.Build.0 = Release|Any CPU - {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.Build.0 = Release|Any CPU - {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.Build.0 = Release|Any CPU - {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.Build.0 = Release|Any CPU - {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.Build.0 = Release|Any CPU - {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.Build.0 = Release|Any CPU - {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.Build.0 = Release|Any CPU - {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.Build.0 = Release|Any CPU - {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.Build.0 = Release|Any CPU - {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.Build.0 = Debug|Any CPU - {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.ActiveCfg = Release|Any CPU - {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.Build.0 = Release|Any CPU - {A63897D9-9531-989B-7309-E384BCFC2BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A63897D9-9531-989B-7309-E384BCFC2BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A63897D9-9531-989B-7309-E384BCFC2BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A63897D9-9531-989B-7309-E384BCFC2BB9}.Release|Any CPU.Build.0 = Release|Any CPU - {8C594D82-3463-3367-4F06-900AC707753D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C594D82-3463-3367-4F06-900AC707753D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C594D82-3463-3367-4F06-900AC707753D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C594D82-3463-3367-4F06-900AC707753D}.Release|Any CPU.Build.0 = Release|Any CPU - {52F400CD-D473-7A1F-7986-89011CD2A887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {52F400CD-D473-7A1F-7986-89011CD2A887}.Debug|Any CPU.Build.0 = Debug|Any CPU - {52F400CD-D473-7A1F-7986-89011CD2A887}.Release|Any CPU.ActiveCfg = Release|Any CPU - {52F400CD-D473-7A1F-7986-89011CD2A887}.Release|Any CPU.Build.0 = Release|Any CPU - {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Release|Any CPU.Build.0 = Release|Any CPU - {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Release|Any CPU.Build.0 = Release|Any CPU - {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Release|Any CPU.Build.0 = Release|Any CPU - {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Release|Any CPU.Build.0 = Release|Any CPU - {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Release|Any CPU.Build.0 = Release|Any CPU - {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Release|Any CPU.Build.0 = Release|Any CPU - {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Release|Any CPU.Build.0 = Release|Any CPU - {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Release|Any CPU.Build.0 = Release|Any CPU - {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Release|Any CPU.Build.0 = Release|Any CPU - {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Release|Any CPU.Build.0 = Release|Any CPU - {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Release|Any CPU.Build.0 = Release|Any CPU - {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Release|Any CPU.Build.0 = Release|Any CPU - {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Release|Any CPU.Build.0 = Release|Any CPU - {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Release|Any CPU.Build.0 = Release|Any CPU - {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Release|Any CPU.Build.0 = Release|Any CPU - {20D1569C-2A47-38B8-075E-47225B674394}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {20D1569C-2A47-38B8-075E-47225B674394}.Debug|Any CPU.Build.0 = Debug|Any CPU - {20D1569C-2A47-38B8-075E-47225B674394}.Release|Any CPU.ActiveCfg = Release|Any CPU - {20D1569C-2A47-38B8-075E-47225B674394}.Release|Any CPU.Build.0 = Release|Any CPU - {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Release|Any CPU.Build.0 = Release|Any CPU - {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Release|Any CPU.Build.0 = Release|Any CPU - {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Release|Any CPU.Build.0 = Release|Any CPU - {6A93F807-4839-1633-8B24-810660BB4C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A93F807-4839-1633-8B24-810660BB4C28}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A93F807-4839-1633-8B24-810660BB4C28}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A93F807-4839-1633-8B24-810660BB4C28}.Release|Any CPU.Build.0 = Release|Any CPU - {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Release|Any CPU.Build.0 = Release|Any CPU - {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Release|Any CPU.Build.0 = Release|Any CPU - {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Release|Any CPU.Build.0 = Release|Any CPU - {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Debug|Any CPU.Build.0 = Debug|Any CPU - {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Release|Any CPU.ActiveCfg = Release|Any CPU - {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Release|Any CPU.Build.0 = Release|Any CPU - {7F71BC11-72B7-7FA6-ADF2-A9FEB112173B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7F71BC11-72B7-7FA6-ADF2-A9FEB112173B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7F71BC11-72B7-7FA6-ADF2-A9FEB112173B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7F71BC11-72B7-7FA6-ADF2-A9FEB112173B}.Release|Any CPU.Build.0 = Release|Any CPU - {CF56A612-A1A4-4C27-1CFD-9F69423B91A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CF56A612-A1A4-4C27-1CFD-9F69423B91A8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CF56A612-A1A4-4C27-1CFD-9F69423B91A8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CF56A612-A1A4-4C27-1CFD-9F69423B91A8}.Release|Any CPU.Build.0 = Release|Any CPU - {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.Build.0 = Release|Any CPU - {79104479-B087-E5D0-5523-F1803282A246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {79104479-B087-E5D0-5523-F1803282A246}.Debug|Any CPU.Build.0 = Debug|Any CPU - {79104479-B087-E5D0-5523-F1803282A246}.Release|Any CPU.ActiveCfg = Release|Any CPU - {79104479-B087-E5D0-5523-F1803282A246}.Release|Any CPU.Build.0 = Release|Any CPU - {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Release|Any CPU.Build.0 = Release|Any CPU - {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {F310596E-88BB-9E54-885E-21C61971917E} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} - {D9492ED1-A812-924B-65E4-F518592B49BB} = {F310596E-88BB-9E54-885E-21C61971917E} - {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} = {D9492ED1-A812-924B-65E4-F518592B49BB} - {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} - {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} = {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} - {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} - {C494ECBE-DEA5-3576-D2AF-200FF12BC144} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} - {7E890DF9-B715-B6DF-2498-FD74DDA87D71} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} - {64689413-46D7-8499-68A6-B6367ACBC597} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} - {FC018E5B-1E2F-DE19-1E97-0C845058C469} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} - {1BE5B76C-B486-560B-6CB2-44C6537249AA} = {FC018E5B-1E2F-DE19-1E97-0C845058C469} - {F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} - {3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} - {6FA01E92-606B-0CB8-8583-6F693A903CFC} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} - {A5994E92-7E0E-89FE-5628-DE1A0176B8BA} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} - {54C11B29-4C54-7255-AB44-BEB63AF9BD1F} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} - {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} - {79E122F4-2325-3E92-438E-5825A307B594} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {66557252-B5C4-664B-D807-07018C627474} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {7203223D-FF02-7BEB-2798-D1639ACC01C4} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {3C69853C-90E3-D889-1960-3B9229882590} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {643E4D4C-BC96-A37F-E0EC-488127F0B127} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {C896CC0A-F5E6-9AA4-C582-E691441F8D32} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {225D9926-4AE8-E539-70AD-8698E688F271} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {FCD529E0-DD17-6587-B29C-12D425C0AD0C} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {61B23570-4F2D-B060-BE1F-37995682E494} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {772B02B5-6280-E1D4-3E2E-248D0455C2FB} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {8380A20C-A5B8-EE91-1A58-270323688CB9} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} - {90659617-4DF7-809A-4E5B-29BB5A98E8E1} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} - {AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9} = {90659617-4DF7-809A-4E5B-29BB5A98E8E1} - {CEDC2447-F717-3C95-7E08-F214D575A7B7} = {AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9} - {E338BD91-C9B3-1381-0E6D-C7D6F3145D11} = {A5C98087-E847-D2C4-2143-20869479839D} - {40641C63-8727-2D4E-D16F-0AF601D7DCB4} = {A5C98087-E847-D2C4-2143-20869479839D} - {BEDC7AEF-DD2F-5B51-C82D-6FE172266513} = {A5C98087-E847-D2C4-2143-20869479839D} - {E14EA762-C24A-4693-F9DA-A54957B72DFA} = {A5C98087-E847-D2C4-2143-20869479839D} - {8EDDC9F9-2451-AB05-31C3-ABB48581A31C} = {A5C98087-E847-D2C4-2143-20869479839D} - {F6C88F41-9490-9667-CD1C-221CBE94A5C7} = {A5C98087-E847-D2C4-2143-20869479839D} - {6A82784D-6054-BBBC-5748-9DC29A2352EE} = {A5C98087-E847-D2C4-2143-20869479839D} - {8B550BAB-63D9-82E7-32E6-5380DC5E1414} = {A5C98087-E847-D2C4-2143-20869479839D} - {68C1FD92-8269-2F2D-C47A-912AF1615116} = {A5C98087-E847-D2C4-2143-20869479839D} - {FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21} = {A5C98087-E847-D2C4-2143-20869479839D} - {BE909FA3-21B8-0909-9B5B-159834E28415} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {56E267CB-08C2-7354-4345-7F14A889F768} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {EFA84DBD-F726-77DB-8E21-109F91237278} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {D0BD3E6B-D57E-9876-9991-8AB2F2EE095E} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {8D451E6C-7255-6BD0-BD0F-15D78B406B95} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {4F968D82-12A8-670A-2F16-AAE12A86F34A} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {4717E12D-C212-8CA0-4A94-CE6CA566B670} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {BB8833D5-6614-CEAD-39C0-760E86D5EFFA} = {BB76B5A5-14BA-E317-828D-110B711D71F5} - {AD31623A-BC43-52C2-D906-AC1D8784A541} = {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} - {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214} = {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} - {DE5BF139-1E5C-D6EA-4FAA-661EF353A194} = {C494ECBE-DEA5-3576-D2AF-200FF12BC144} - {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA} = {7E890DF9-B715-B6DF-2498-FD74DDA87D71} - {97F94029-5419-6187-5A63-5C8FD9232FAE} = {64689413-46D7-8499-68A6-B6367ACBC597} - {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60} = {79E122F4-2325-3E92-438E-5825A307B594} - {92C62F7B-8028-6EE1-B71B-F45F459B8E97} = {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} - {F664A948-E352-5808-E780-77A03F19E93E} = {66557252-B5C4-664B-D807-07018C627474} - {FA83F778-5252-0B80-5555-E69F790322EA} = {7203223D-FF02-7BEB-2798-D1639ACC01C4} - {C53E0895-879A-D9E6-0A43-24AD17A2F270} = {3C69853C-90E3-D889-1960-3B9229882590} - {0AED303F-69E6-238F-EF80-81985080EDB7} = {643E4D4C-BC96-A37F-E0EC-488127F0B127} - {2904D288-CE64-A565-2C46-C2E85A96A1EE} = {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} - {A6667CC3-B77F-023E-3A67-05F99E9FF46A} = {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} - {A26E2816-F787-F76B-1D6C-E086DD3E19CE} = {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} - {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877} = {C896CC0A-F5E6-9AA4-C582-E691441F8D32} - {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6} = {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} - {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA} = {225D9926-4AE8-E539-70AD-8698E688F271} - {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1} = {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} - {632A1F0D-1BA5-C84B-B716-2BE638A92780} = {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} - {A63897D9-9531-989B-7309-E384BCFC2BB9} = {FCD529E0-DD17-6587-B29C-12D425C0AD0C} - {8C594D82-3463-3367-4F06-900AC707753D} = {61B23570-4F2D-B060-BE1F-37995682E494} - {52F400CD-D473-7A1F-7986-89011CD2A887} = {CEDC2447-F717-3C95-7E08-F214D575A7B7} - {97998C88-E6E1-D5E2-B632-537B58E00CBF} = {F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05} - {BAD08D96-A80A-D27F-5D9C-656AEEB3D568} = {3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B} - {F63694F1-B56D-6E72-3F5D-5D38B1541F0F} = {6FA01E92-606B-0CB8-8583-6F693A903CFC} - {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D} = {E338BD91-C9B3-1381-0E6D-C7D6F3145D11} - {1191C6F4-CDD4-D9B3-5723-59A17A1411C3} = {BE909FA3-21B8-0909-9B5B-159834E28415} - {B1AC2364-514D-CE6D-3387-9BFACF63C17C} = {40641C63-8727-2D4E-D16F-0AF601D7DCB4} - {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99} = {BEDC7AEF-DD2F-5B51-C82D-6FE172266513} - {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9} = {56E267CB-08C2-7354-4345-7F14A889F768} - {0BA516C5-5B21-B0A8-60CF-00A4A744B46D} = {E14EA762-C24A-4693-F9DA-A54957B72DFA} - {D1C7E5AC-931A-3084-6236-F3B2605DFC33} = {EFA84DBD-F726-77DB-8E21-109F91237278} - {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0} = {8EDDC9F9-2451-AB05-31C3-ABB48581A31C} - {DCAEB360-E6CD-D87F-6750-6738A0C7534A} = {6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E} - {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC} = {48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B} - {8ED04856-EACE-5385-CDFB-BBA78C545AA7} = {F6C88F41-9490-9667-CD1C-221CBE94A5C7} - {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843} = {D0BD3E6B-D57E-9876-9991-8AB2F2EE095E} - {20D1569C-2A47-38B8-075E-47225B674394} = {6A82784D-6054-BBBC-5748-9DC29A2352EE} - {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F} = {1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16} - {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7} = {8B550BAB-63D9-82E7-32E6-5380DC5E1414} - {467044CF-485E-3FAC-ABB8-DDB13A61D62F} = {8D451E6C-7255-6BD0-BD0F-15D78B406B95} - {6A93F807-4839-1633-8B24-810660BB4C28} = {68C1FD92-8269-2F2D-C47A-912AF1615116} - {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525} = {4F968D82-12A8-670A-2F16-AAE12A86F34A} - {5634B7CF-C0A3-96C9-21FA-4090705F71BD} = {FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21} - {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6} = {82766F65-4DE9-1353-CBF5-6E204D3035F8} - {121E7D7D-F374-DE95-423B-2BDDDE91D063} = {4717E12D-C212-8CA0-4A94-CE6CA566B670} - {7F71BC11-72B7-7FA6-ADF2-A9FEB112173B} = {423298FC-947A-D3B2-1B79-C062E674B983} - {CF56A612-A1A4-4C27-1CFD-9F69423B91A8} = {BB8833D5-6614-CEAD-39C0-760E86D5EFFA} - {38A9EE9B-6FC8-93BC-0D43-2A906E678D66} = {772B02B5-6280-E1D4-3E2E-248D0455C2FB} - {79104479-B087-E5D0-5523-F1803282A246} = {A5994E92-7E0E-89FE-5628-DE1A0176B8BA} - {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D} = {54C11B29-4C54-7255-AB44-BEB63AF9BD1F} - {AF043113-CCE3-59C1-DF71-9804155F26A8} = {8380A20C-A5B8-EE91-1A58-270323688CB9} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {AE700933-7CAA-C4C6-EC10-27CD4DCAFFF2} - EndGlobalSection -EndGlobal - +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService", "{82766F65-4DE9-1353-CBF5-6E204D3035F8}" + +EndProject + + + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AirGap", "AirGap", "{F310596E-88BB-9E54-885E-21C61971917E}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{D9492ED1-A812-924B-65E4-F518592B49BB}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{3823DE1E-2ACE-C956-99E1-00DB786D9E1D}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Client", "StellaOps.Auth.Client", "{C494ECBE-DEA5-3576-D2AF-200FF12BC144}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Router", "Router", "{FC018E5B-1E2F-DE19-1E97-0C845058C469}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1BE5B76C-B486-560B-6CB2-44C6537249AA}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice", "StellaOps.Microservice", "{3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.AspNetCore", "StellaOps.Microservice.AspNetCore", "{6FA01E92-606B-0CB8-8583-6F693A903CFC}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.AspNet", "StellaOps.Router.AspNet", "{A5994E92-7E0E-89FE-5628-DE1A0176B8BA}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common", "StellaOps.Router.Common", "{54C11B29-4C54-7255-AB44-BEB63AF9BD1F}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Email", "StellaOps.Notify.Connectors.Email", "{E338BD91-C9B3-1381-0E6D-C7D6F3145D11}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Shared", "StellaOps.Notify.Connectors.Shared", "{40641C63-8727-2D4E-D16F-0AF601D7DCB4}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Slack", "StellaOps.Notify.Connectors.Slack", "{BEDC7AEF-DD2F-5B51-C82D-6FE172266513}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Teams", "StellaOps.Notify.Connectors.Teams", "{E14EA762-C24A-4693-F9DA-A54957B72DFA}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Webhook", "StellaOps.Notify.Connectors.Webhook", "{8EDDC9F9-2451-AB05-31C3-ABB48581A31C}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Engine", "StellaOps.Notify.Engine", "{F6C88F41-9490-9667-CD1C-221CBE94A5C7}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Models", "StellaOps.Notify.Models", "{6A82784D-6054-BBBC-5748-9DC29A2352EE}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Persistence", "StellaOps.Notify.Persistence", "{8B550BAB-63D9-82E7-32E6-5380DC5E1414}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Queue", "StellaOps.Notify.Queue", "{68C1FD92-8269-2F2D-C47A-912AF1615116}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Storage.InMemory", "StellaOps.Notify.Storage.InMemory", "{FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Email.Tests", "StellaOps.Notify.Connectors.Email.Tests", "{BE909FA3-21B8-0909-9B5B-159834E28415}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Slack.Tests", "StellaOps.Notify.Connectors.Slack.Tests", "{56E267CB-08C2-7354-4345-7F14A889F768}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Teams.Tests", "StellaOps.Notify.Connectors.Teams.Tests", "{EFA84DBD-F726-77DB-8E21-109F91237278}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Connectors.Webhook.Tests", "StellaOps.Notify.Connectors.Webhook.Tests", "{6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Core.Tests", "StellaOps.Notify.Core.Tests", "{48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Engine.Tests", "StellaOps.Notify.Engine.Tests", "{D0BD3E6B-D57E-9876-9991-8AB2F2EE095E}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Models.Tests", "StellaOps.Notify.Models.Tests", "{1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Persistence.Tests", "StellaOps.Notify.Persistence.Tests", "{8D451E6C-7255-6BD0-BD0F-15D78B406B95}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.Queue.Tests", "StellaOps.Notify.Queue.Tests", "{4F968D82-12A8-670A-2F16-AAE12A86F34A}" + +EndProject + +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Notify.WebService.Tests", "StellaOps.Notify.WebService.Tests", "{4717E12D-C212-8CA0-4A94-CE6CA566B670}" + +EndProject + + + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "..\\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "..\\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "..\\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "..\\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "..\\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "..\\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "..\\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "..\\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "..\\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "..\\Router\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "..\\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email", "__Libraries\StellaOps.Notify.Connectors.Email\StellaOps.Notify.Connectors.Email.csproj", "{1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email.Tests", "__Tests\StellaOps.Notify.Connectors.Email.Tests\StellaOps.Notify.Connectors.Email.Tests.csproj", "{1191C6F4-CDD4-D9B3-5723-59A17A1411C3}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Shared", "__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj", "{B1AC2364-514D-CE6D-3387-9BFACF63C17C}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack", "__Libraries\StellaOps.Notify.Connectors.Slack\StellaOps.Notify.Connectors.Slack.csproj", "{B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack.Tests", "__Tests\StellaOps.Notify.Connectors.Slack.Tests\StellaOps.Notify.Connectors.Slack.Tests.csproj", "{CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams", "__Libraries\StellaOps.Notify.Connectors.Teams\StellaOps.Notify.Connectors.Teams.csproj", "{0BA516C5-5B21-B0A8-60CF-00A4A744B46D}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams.Tests", "__Tests\StellaOps.Notify.Connectors.Teams.Tests\StellaOps.Notify.Connectors.Teams.Tests.csproj", "{D1C7E5AC-931A-3084-6236-F3B2605DFC33}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook", "__Libraries\StellaOps.Notify.Connectors.Webhook\StellaOps.Notify.Connectors.Webhook.csproj", "{6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook.Tests", "__Tests\StellaOps.Notify.Connectors.Webhook.Tests\StellaOps.Notify.Connectors.Webhook.Tests.csproj", "{DCAEB360-E6CD-D87F-6750-6738A0C7534A}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Core.Tests", "__Tests\StellaOps.Notify.Core.Tests\StellaOps.Notify.Core.Tests.csproj", "{09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{8ED04856-EACE-5385-CDFB-BBA78C545AA7}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine.Tests", "__Tests\StellaOps.Notify.Engine.Tests\StellaOps.Notify.Engine.Tests.csproj", "{DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{20D1569C-2A47-38B8-075E-47225B674394}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tests", "__Tests\StellaOps.Notify.Models.Tests\StellaOps.Notify.Models.Tests.csproj", "{FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Persistence", "__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj", "{2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Persistence.Tests", "__Tests\StellaOps.Notify.Persistence.Tests\StellaOps.Notify.Persistence.Tests.csproj", "{467044CF-485E-3FAC-ABB8-DDB13A61D62F}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{6A93F807-4839-1633-8B24-810660BB4C28}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.InMemory", "__Libraries\StellaOps.Notify.Storage.InMemory\StellaOps.Notify.Storage.InMemory.csproj", "{5634B7CF-C0A3-96C9-21FA-4090705F71BD}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService\StellaOps.Notify.WebService.csproj", "{B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{121E7D7D-F374-DE95-423B-2BDDDE91D063}" + +EndProject + + + + + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "..\\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "..\\Router\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}" + +EndProject + +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "..\\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}" + +EndProject + +Global + + GlobalSection(SolutionConfigurationPlatforms) = preSolution + + Debug|Any CPU = Debug|Any CPU + + Release|Any CPU = Release|Any CPU + + EndGlobalSection + + GlobalSection(ProjectConfigurationPlatforms) = postSolution + + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.Build.0 = Release|Any CPU + + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.Build.0 = Release|Any CPU + + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.Build.0 = Release|Any CPU + + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.Build.0 = Release|Any CPU + + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.Build.0 = Release|Any CPU + + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU + + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU + + {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.Build.0 = Release|Any CPU + + {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.Build.0 = Release|Any CPU + + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.Build.0 = Release|Any CPU + + {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.Build.0 = Release|Any CPU + + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.Build.0 = Release|Any CPU + + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.Build.0 = Release|Any CPU + + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.Build.0 = Release|Any CPU + + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.Build.0 = Release|Any CPU + + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.Build.0 = Release|Any CPU + + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.Build.0 = Release|Any CPU + + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.Build.0 = Release|Any CPU + + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.Build.0 = Release|Any CPU + + {A63897D9-9531-989B-7309-E384BCFC2BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {A63897D9-9531-989B-7309-E384BCFC2BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {A63897D9-9531-989B-7309-E384BCFC2BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {A63897D9-9531-989B-7309-E384BCFC2BB9}.Release|Any CPU.Build.0 = Release|Any CPU + + {8C594D82-3463-3367-4F06-900AC707753D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {8C594D82-3463-3367-4F06-900AC707753D}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {8C594D82-3463-3367-4F06-900AC707753D}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {8C594D82-3463-3367-4F06-900AC707753D}.Release|Any CPU.Build.0 = Release|Any CPU + + {52F400CD-D473-7A1F-7986-89011CD2A887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {52F400CD-D473-7A1F-7986-89011CD2A887}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {52F400CD-D473-7A1F-7986-89011CD2A887}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {52F400CD-D473-7A1F-7986-89011CD2A887}.Release|Any CPU.Build.0 = Release|Any CPU + + {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {97998C88-E6E1-D5E2-B632-537B58E00CBF}.Release|Any CPU.Build.0 = Release|Any CPU + + {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {BAD08D96-A80A-D27F-5D9C-656AEEB3D568}.Release|Any CPU.Build.0 = Release|Any CPU + + {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {F63694F1-B56D-6E72-3F5D-5D38B1541F0F}.Release|Any CPU.Build.0 = Release|Any CPU + + {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D}.Release|Any CPU.Build.0 = Release|Any CPU + + {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {1191C6F4-CDD4-D9B3-5723-59A17A1411C3}.Release|Any CPU.Build.0 = Release|Any CPU + + {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {B1AC2364-514D-CE6D-3387-9BFACF63C17C}.Release|Any CPU.Build.0 = Release|Any CPU + + {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99}.Release|Any CPU.Build.0 = Release|Any CPU + + {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9}.Release|Any CPU.Build.0 = Release|Any CPU + + {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {0BA516C5-5B21-B0A8-60CF-00A4A744B46D}.Release|Any CPU.Build.0 = Release|Any CPU + + {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {D1C7E5AC-931A-3084-6236-F3B2605DFC33}.Release|Any CPU.Build.0 = Release|Any CPU + + {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0}.Release|Any CPU.Build.0 = Release|Any CPU + + {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {DCAEB360-E6CD-D87F-6750-6738A0C7534A}.Release|Any CPU.Build.0 = Release|Any CPU + + {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC}.Release|Any CPU.Build.0 = Release|Any CPU + + {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {8ED04856-EACE-5385-CDFB-BBA78C545AA7}.Release|Any CPU.Build.0 = Release|Any CPU + + {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843}.Release|Any CPU.Build.0 = Release|Any CPU + + {20D1569C-2A47-38B8-075E-47225B674394}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {20D1569C-2A47-38B8-075E-47225B674394}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {20D1569C-2A47-38B8-075E-47225B674394}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {20D1569C-2A47-38B8-075E-47225B674394}.Release|Any CPU.Build.0 = Release|Any CPU + + {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F}.Release|Any CPU.Build.0 = Release|Any CPU + + {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7}.Release|Any CPU.Build.0 = Release|Any CPU + + {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {467044CF-485E-3FAC-ABB8-DDB13A61D62F}.Release|Any CPU.Build.0 = Release|Any CPU + + {6A93F807-4839-1633-8B24-810660BB4C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {6A93F807-4839-1633-8B24-810660BB4C28}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {6A93F807-4839-1633-8B24-810660BB4C28}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {6A93F807-4839-1633-8B24-810660BB4C28}.Release|Any CPU.Build.0 = Release|Any CPU + + {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525}.Release|Any CPU.Build.0 = Release|Any CPU + + {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {5634B7CF-C0A3-96C9-21FA-4090705F71BD}.Release|Any CPU.Build.0 = Release|Any CPU + + {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6}.Release|Any CPU.Build.0 = Release|Any CPU + + {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {121E7D7D-F374-DE95-423B-2BDDDE91D063}.Release|Any CPU.Build.0 = Release|Any CPU + + + + + + + + + + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.Build.0 = Release|Any CPU + + {79104479-B087-E5D0-5523-F1803282A246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {79104479-B087-E5D0-5523-F1803282A246}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {79104479-B087-E5D0-5523-F1803282A246}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {79104479-B087-E5D0-5523-F1803282A246}.Release|Any CPU.Build.0 = Release|Any CPU + + {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}.Release|Any CPU.Build.0 = Release|Any CPU + + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.Build.0 = Release|Any CPU + + EndGlobalSection + + GlobalSection(SolutionProperties) = preSolution + + HideSolutionNode = FALSE + + EndGlobalSection + + GlobalSection(NestedProjects) = preSolution + + {F310596E-88BB-9E54-885E-21C61971917E} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + + {D9492ED1-A812-924B-65E4-F518592B49BB} = {F310596E-88BB-9E54-885E-21C61971917E} + + {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} = {D9492ED1-A812-924B-65E4-F518592B49BB} + + {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + + {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} = {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} + + {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + + {C494ECBE-DEA5-3576-D2AF-200FF12BC144} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + + {7E890DF9-B715-B6DF-2498-FD74DDA87D71} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + + {64689413-46D7-8499-68A6-B6367ACBC597} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + + {FC018E5B-1E2F-DE19-1E97-0C845058C469} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + + {1BE5B76C-B486-560B-6CB2-44C6537249AA} = {FC018E5B-1E2F-DE19-1E97-0C845058C469} + + {F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} + + {3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} + + {6FA01E92-606B-0CB8-8583-6F693A903CFC} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} + + {A5994E92-7E0E-89FE-5628-DE1A0176B8BA} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} + + {54C11B29-4C54-7255-AB44-BEB63AF9BD1F} = {1BE5B76C-B486-560B-6CB2-44C6537249AA} + + {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + + {79E122F4-2325-3E92-438E-5825A307B594} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {66557252-B5C4-664B-D807-07018C627474} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {7203223D-FF02-7BEB-2798-D1639ACC01C4} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {3C69853C-90E3-D889-1960-3B9229882590} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {643E4D4C-BC96-A37F-E0EC-488127F0B127} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {C896CC0A-F5E6-9AA4-C582-E691441F8D32} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {225D9926-4AE8-E539-70AD-8698E688F271} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {FCD529E0-DD17-6587-B29C-12D425C0AD0C} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {61B23570-4F2D-B060-BE1F-37995682E494} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {772B02B5-6280-E1D4-3E2E-248D0455C2FB} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {8380A20C-A5B8-EE91-1A58-270323688CB9} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + + {90659617-4DF7-809A-4E5B-29BB5A98E8E1} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + + {AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9} = {90659617-4DF7-809A-4E5B-29BB5A98E8E1} + + {CEDC2447-F717-3C95-7E08-F214D575A7B7} = {AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9} + + {E338BD91-C9B3-1381-0E6D-C7D6F3145D11} = {A5C98087-E847-D2C4-2143-20869479839D} + + {40641C63-8727-2D4E-D16F-0AF601D7DCB4} = {A5C98087-E847-D2C4-2143-20869479839D} + + {BEDC7AEF-DD2F-5B51-C82D-6FE172266513} = {A5C98087-E847-D2C4-2143-20869479839D} + + {E14EA762-C24A-4693-F9DA-A54957B72DFA} = {A5C98087-E847-D2C4-2143-20869479839D} + + {8EDDC9F9-2451-AB05-31C3-ABB48581A31C} = {A5C98087-E847-D2C4-2143-20869479839D} + + {F6C88F41-9490-9667-CD1C-221CBE94A5C7} = {A5C98087-E847-D2C4-2143-20869479839D} + + {6A82784D-6054-BBBC-5748-9DC29A2352EE} = {A5C98087-E847-D2C4-2143-20869479839D} + + {8B550BAB-63D9-82E7-32E6-5380DC5E1414} = {A5C98087-E847-D2C4-2143-20869479839D} + + {68C1FD92-8269-2F2D-C47A-912AF1615116} = {A5C98087-E847-D2C4-2143-20869479839D} + + {FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21} = {A5C98087-E847-D2C4-2143-20869479839D} + + {BE909FA3-21B8-0909-9B5B-159834E28415} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {56E267CB-08C2-7354-4345-7F14A889F768} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {EFA84DBD-F726-77DB-8E21-109F91237278} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {D0BD3E6B-D57E-9876-9991-8AB2F2EE095E} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {8D451E6C-7255-6BD0-BD0F-15D78B406B95} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {4F968D82-12A8-670A-2F16-AAE12A86F34A} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + {4717E12D-C212-8CA0-4A94-CE6CA566B670} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + + + {AD31623A-BC43-52C2-D906-AC1D8784A541} = {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} + + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214} = {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} + + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194} = {C494ECBE-DEA5-3576-D2AF-200FF12BC144} + + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA} = {7E890DF9-B715-B6DF-2498-FD74DDA87D71} + + {97F94029-5419-6187-5A63-5C8FD9232FAE} = {64689413-46D7-8499-68A6-B6367ACBC597} + + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60} = {79E122F4-2325-3E92-438E-5825A307B594} + + {92C62F7B-8028-6EE1-B71B-F45F459B8E97} = {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} + + {F664A948-E352-5808-E780-77A03F19E93E} = {66557252-B5C4-664B-D807-07018C627474} + + {FA83F778-5252-0B80-5555-E69F790322EA} = {7203223D-FF02-7BEB-2798-D1639ACC01C4} + + {C53E0895-879A-D9E6-0A43-24AD17A2F270} = {3C69853C-90E3-D889-1960-3B9229882590} + + {0AED303F-69E6-238F-EF80-81985080EDB7} = {643E4D4C-BC96-A37F-E0EC-488127F0B127} + + {2904D288-CE64-A565-2C46-C2E85A96A1EE} = {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} + + {A6667CC3-B77F-023E-3A67-05F99E9FF46A} = {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} + + {A26E2816-F787-F76B-1D6C-E086DD3E19CE} = {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} + + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877} = {C896CC0A-F5E6-9AA4-C582-E691441F8D32} + + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6} = {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} + + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA} = {225D9926-4AE8-E539-70AD-8698E688F271} + + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1} = {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} + + {632A1F0D-1BA5-C84B-B716-2BE638A92780} = {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} + + {A63897D9-9531-989B-7309-E384BCFC2BB9} = {FCD529E0-DD17-6587-B29C-12D425C0AD0C} + + {8C594D82-3463-3367-4F06-900AC707753D} = {61B23570-4F2D-B060-BE1F-37995682E494} + + {52F400CD-D473-7A1F-7986-89011CD2A887} = {CEDC2447-F717-3C95-7E08-F214D575A7B7} + + {97998C88-E6E1-D5E2-B632-537B58E00CBF} = {F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05} + + {BAD08D96-A80A-D27F-5D9C-656AEEB3D568} = {3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B} + + {F63694F1-B56D-6E72-3F5D-5D38B1541F0F} = {6FA01E92-606B-0CB8-8583-6F693A903CFC} + + {1CE38E04-93AE-B9F1-6D6F-9B4E76C9465D} = {E338BD91-C9B3-1381-0E6D-C7D6F3145D11} + + {1191C6F4-CDD4-D9B3-5723-59A17A1411C3} = {BE909FA3-21B8-0909-9B5B-159834E28415} + + {B1AC2364-514D-CE6D-3387-9BFACF63C17C} = {40641C63-8727-2D4E-D16F-0AF601D7DCB4} + + {B82BE737-B24F-ACA1-F35F-99AA5A1F7D99} = {BEDC7AEF-DD2F-5B51-C82D-6FE172266513} + + {CEEE62CA-41D0-63D6-0D6A-769CE0A480A9} = {56E267CB-08C2-7354-4345-7F14A889F768} + + {0BA516C5-5B21-B0A8-60CF-00A4A744B46D} = {E14EA762-C24A-4693-F9DA-A54957B72DFA} + + {D1C7E5AC-931A-3084-6236-F3B2605DFC33} = {EFA84DBD-F726-77DB-8E21-109F91237278} + + {6F40BA6C-2D73-E5ED-7AA8-4749EE10DCE0} = {8EDDC9F9-2451-AB05-31C3-ABB48581A31C} + + {DCAEB360-E6CD-D87F-6750-6738A0C7534A} = {6FC7F1BF-A8E4-20CD-74E6-72F7D5FFBF0E} + + {09F0BFD6-9CF6-0CE7-BBD6-EE880406A2BC} = {48D37C6D-8BE1-2B29-4A5A-D2CD2CD2429B} + + {8ED04856-EACE-5385-CDFB-BBA78C545AA7} = {F6C88F41-9490-9667-CD1C-221CBE94A5C7} + + {DC320F8B-BDA9-62D9-0DF4-75EF85A4D843} = {D0BD3E6B-D57E-9876-9991-8AB2F2EE095E} + + {20D1569C-2A47-38B8-075E-47225B674394} = {6A82784D-6054-BBBC-5748-9DC29A2352EE} + + {FBF3CF7E-F15B-BDD8-D993-CF466DF8832F} = {1FDFA1A9-9519-1464-7CE7-8CAFB9C65F16} + + {2F7AA715-25AE-086A-7DF4-CAB5EF00E2B7} = {8B550BAB-63D9-82E7-32E6-5380DC5E1414} + + {467044CF-485E-3FAC-ABB8-DDB13A61D62F} = {8D451E6C-7255-6BD0-BD0F-15D78B406B95} + + {6A93F807-4839-1633-8B24-810660BB4C28} = {68C1FD92-8269-2F2D-C47A-912AF1615116} + + {7D79521A-44F5-9BE1-2EA6-64EEAAA0D525} = {4F968D82-12A8-670A-2F16-AAE12A86F34A} + + {5634B7CF-C0A3-96C9-21FA-4090705F71BD} = {FEEA42E8-56D3-2F63-6D6D-A1C6379EBF21} + + {B79FE3C1-6362-7B64-0DA2-5EC59B62CCC6} = {82766F65-4DE9-1353-CBF5-6E204D3035F8} + + {121E7D7D-F374-DE95-423B-2BDDDE91D063} = {4717E12D-C212-8CA0-4A94-CE6CA566B670} + + + + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66} = {772B02B5-6280-E1D4-3E2E-248D0455C2FB} + + {79104479-B087-E5D0-5523-F1803282A246} = {A5994E92-7E0E-89FE-5628-DE1A0176B8BA} + + {F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D} = {54C11B29-4C54-7255-AB44-BEB63AF9BD1F} + + {AF043113-CCE3-59C1-DF71-9804155F26A8} = {8380A20C-A5B8-EE91-1A58-270323688CB9} + + EndGlobalSection + + GlobalSection(ExtensibilityGlobals) = postSolution + + SolutionGuid = {AE700933-7CAA-C4C6-EC10-27CD4DCAFFF2} + + EndGlobalSection + +EndGlobal + + diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 6f2a47df1..63e582bb8 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -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" },