using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using StellaOps.Localization; using static StellaOps.Localization.T; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; using Serilog; using Serilog.Events; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Configuration; using StellaOps.Notify.Models; 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; using StellaOps.Notify.WebService.Hosting; using StellaOps.Notify.WebService.Internal; using StellaOps.Notify.WebService.Options; using StellaOps.Notify.WebService.Plugins; 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; using System.Globalization; using System.IO; using System.Linq; using System.Security.Claims; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddStellaOpsDefaults(options => { options.BasePath = builder.Environment.ContentRootPath; options.EnvironmentPrefix = "NOTIFY_"; options.ConfigureBuilder = configurationBuilder => { configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml")); }; }); var contentRootPath = builder.Environment.ContentRootPath; var bootstrapOptions = builder.Configuration.BindOptions( NotifyWebServiceOptions.SectionName, (opts, _) => { NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath); NotifyWebServiceOptionsValidator.Validate(opts); }); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName)) .PostConfigure(options => { NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath); NotifyWebServiceOptionsValidator.Validate(options); }) .ValidateOnStart(); builder.Host.UseSerilog((context, services, loggerConfiguration) => { var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel); loggerConfiguration .MinimumLevel.Is(minimumLevel) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console(); }); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // PostgreSQL is the canonical Notify storage; enable Postgres-backed repositories. builder.Services.AddNotifyPersistence(builder.Configuration, sectionName: "Postgres:Notify"); var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath); builder.Services.AddSingleton(pluginHostOptions); builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); 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); builder.Services.AddEndpointsApiExplorer(); // Stella Router integration var routerEnabled = builder.Services.AddRouterMicroservice( builder.Configuration, serviceName: "notify", version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0", routerOptionsSection: "Router"); builder.Services.AddStellaOpsTenantServices(); builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); builder.Services.AddStellaOpsLocalization(builder.Configuration); builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly()); builder.TryAddStellaOpsLocalBinding("notify"); var app = builder.Build(); app.LogStellaOpsLocalHostname("notify"); var readyStatus = app.Services.GetRequiredService(); var resolvedOptions = app.Services.GetRequiredService>().Value; await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions); ConfigureRequestPipeline(app, bootstrapOptions, routerEnabled); // Enable WebSocket support for live incident feed (merged from Notifier) app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); // Tenant context middleware (from Notifier merge) app.UseTenantContext(); ConfigureEndpoints(app); // ========================================================================= // Notifier v2 endpoint mappings (merged from Notifier WebService) // ========================================================================= app.MapNotifyApiV2(); app.MapRuleEndpoints(); app.MapTemplateEndpoints(); app.MapIncidentEndpoints(); app.MapIncidentLiveFeed(); app.MapSimulationEndpoints(); app.MapQuietHoursEndpoints(); app.MapThrottleEndpoints(); app.MapOperatorOverrideEndpoints(); app.MapEscalationEndpoints(); app.MapStormBreakerEndpoints(); app.MapLocalizationEndpoints(); app.MapFallbackEndpoints(); app.MapSecurityEndpoints(); app.MapObservabilityEndpoints(); // ========================================================================= // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.LoadTranslationsAsync(); await app.RunAsync(); static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options, IConfiguration configuration) { // Read enabled flag from configuration to support test overrides via UseSetting var authorityEnabled = configuration.GetValue("notify:authority:enabled") ?? options.Authority.Enabled; if (authorityEnabled) { builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: null, configure: resourceOptions => { resourceOptions.Authority = options.Authority.Issuer; resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata; resourceOptions.MetadataAddress = options.Authority.MetadataAddress; resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds); resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds); resourceOptions.Audiences.Clear(); foreach (var audience in options.Authority.Audiences) { resourceOptions.Audiences.Add(audience); } }); builder.Services.AddAuthorization(auth => { auth.AddStellaOpsScopePolicy(NotifyPolicies.Viewer, options.Authority.ViewerScope); auth.AddPolicy( NotifyPolicies.Operator, policy => policy .RequireAuthenticatedUser() .RequireAssertion(ctx => HasScope(ctx.User, options.Authority.OperatorScope) || HasScope(ctx.User, options.Authority.AdminScope))); auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope); }); } else { // Read allowAnonymousFallback from configuration to support test overrides var allowAnonymous = configuration.GetValue("notify:authority:allowAnonymousFallback") ?? options.Authority.AllowAnonymousFallback; if (allowAnonymous) { builder.Services.AddAuthentication(authOptions => { authOptions.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName; authOptions.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName; }).AddScheme( AllowAllAuthenticationHandler.SchemeName, static _ => { }); builder.Services.AddAuthorization(auth => { auth.AddPolicy( NotifyPolicies.Viewer, policy => policy.RequireAssertion(_ => true)); auth.AddPolicy( NotifyPolicies.Operator, policy => policy.RequireAssertion(_ => true)); auth.AddPolicy( NotifyPolicies.Admin, policy => policy.RequireAssertion(_ => true)); }); } else { builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(jwt => { jwt.RequireHttpsMetadata = false; jwt.IncludeErrorDetails = true; // Read JWT settings from configuration to support test overrides var issuer = configuration["notify:authority:issuer"] ?? options.Authority.Issuer; var audiencesList = configuration.GetSection("notify:authority:audiences").Get() ?? options.Authority.Audiences.ToArray(); var signingKey = configuration["notify:authority:developmentSigningKey"] ?? options.Authority.DevelopmentSigningKey!; jwt.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = audiencesList.Length > 0, ValidAudiences = audiencesList, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds), NameClaimType = ClaimTypes.Name }; }); builder.Services.AddAuthorization(auth => { auth.AddPolicy( NotifyPolicies.Viewer, policy => policy .RequireAuthenticatedUser() .RequireAssertion(ctx => HasScope(ctx.User, options.Authority.ViewerScope) || HasScope(ctx.User, options.Authority.OperatorScope) || HasScope(ctx.User, options.Authority.AdminScope))); auth.AddPolicy( NotifyPolicies.Operator, policy => policy .RequireAuthenticatedUser() .RequireAssertion(ctx => HasScope(ctx.User, options.Authority.OperatorScope) || HasScope(ctx.User, options.Authority.AdminScope))); auth.AddPolicy( NotifyPolicies.Admin, policy => policy .RequireAuthenticatedUser() .RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope))); }); } } } static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options) { ArgumentNullException.ThrowIfNull(options); builder.Services.AddRateLimiter(rateLimiterOptions => { rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; rateLimiterOptions.OnRejected = static (context, _) => { context.HttpContext.Response.Headers.TryAdd("Retry-After", "1"); return ValueTask.CompletedTask; }; ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, o => o.Api.RateLimits.DeliveryHistory, "deliveries"); ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, o => o.Api.RateLimits.TestSend, "channel-test"); }); static void ConfigurePolicy( RateLimiterOptions rateLimiterOptions, string policyName, Func policySelector, string prefix) { rateLimiterOptions.AddPolicy(policyName, httpContext => { var opts = httpContext.RequestServices.GetRequiredService>().Value; var policy = policySelector(opts); var tenantHeader = opts.Api.TenantHeader; if (policy is null || !policy.Enabled) { return RateLimitPartition.GetNoLimiter("notify-disabled"); } var identity = ResolveIdentity(httpContext, tenantHeader, prefix); return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions { TokenLimit = policy.TokenLimit, TokensPerPeriod = policy.TokensPerPeriod, ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds), QueueLimit = policy.QueueLimit, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true }); }); } static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix) { var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header) ? header.ToString().Trim() : "anonymous"; var subject = httpContext.User.FindFirst("sub")?.Value ?? httpContext.User.Identity?.Name ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "anonymous"; return string.Concat(prefix, ':', tenant, ':', subject); } } static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options) { var stopwatch = Stopwatch.StartNew(); try { await using var scope = services.CreateAsyncScope(); var registry = scope.ServiceProvider.GetRequiredService(); var count = await registry.WarmupAsync(); stopwatch.Stop(); status.RecordReadyCheck(success: true, stopwatch.Elapsed); logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count); } catch (Exception ex) { stopwatch.Stop(); status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message); logger.LogError(ex, "Failed to initialise Notify WebService."); throw; } } static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options, bool routerEnabled) { if (options.Telemetry.EnableRequestLogging) { app.UseSerilogRequestLogging(c => { c.IncludeQueryInRequestPath = true; c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error; }); } app.UseStellaOpsCors(); app.UseStellaOpsLocalization(); app.UseAuthentication(); app.UseRateLimiter(); app.UseAuthorization(); app.UseStellaOpsTenantMiddleware(); // Stella Router integration app.TryUseStellaRouter(routerEnabled); } static void ConfigureEndpoints(WebApplication app) { app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })) .WithName("NotifyHealthz") .WithDescription(_t("notify.healthz.description")) .AllowAnonymous(); app.MapGet("/readyz", (ServiceStatus status) => { var snapshot = status.CreateSnapshot(); if (snapshot.Ready.IsReady) { return Results.Ok(new { status = "ready", checkedAt = snapshot.Ready.CheckedAt, latencyMs = snapshot.Ready.Latency?.TotalMilliseconds, snapshot.StartedAt }); } return JsonResponse( new { status = "unready", snapshot.Ready.Error, checkedAt = snapshot.Ready.CheckedAt, latencyMs = snapshot.Ready.Latency?.TotalMilliseconds }, StatusCodes.Status503ServiceUnavailable); }) .WithName("NotifyReadyz") .WithDescription(_t("notify.readyz.description")) .AllowAnonymous(); var options = app.Services.GetRequiredService>().Value; var tenantHeader = options.Api.TenantHeader; var apiBasePath = options.Api.BasePath.TrimEnd('/'); var apiGroup = app.MapGroup(options.Api.BasePath).RequireTenant(); var internalGroup = app.MapGroup(options.Api.InternalBasePath).RequireTenant(); internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule)) .WithName("notify.rules.normalize") .WithDescription(_t("notify.internal.rules_normalize_description")) .RequireAuthorization(NotifyPolicies.Operator) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel)) .WithName("notify.channels.normalize") .WithDescription(_t("notify.internal.channels_normalize_description")) .RequireAuthorization(NotifyPolicies.Operator); internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate)) .WithName("notify.templates.normalize") .WithDescription(_t("notify.internal.templates_normalize_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/rules", async (IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var rules = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false); return JsonResponse(rules.Select(ToNotifyRule)); }) .WithName("NotifyListRules") .WithDescription(_t("notify.rules.list_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(ruleId, out var id)) { return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") }); } var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false); return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule)); }) .WithName("NotifyGetRule") .WithDescription(_t("notify.rules.get_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } NotifyRule ruleModel; try { ruleModel = service.UpgradeRule(body); } catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException) { return Results.BadRequest(new { error = _t("notify.error.rule_payload_invalid", ex.Message) }); } if (!string.Equals(ruleModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") }); } if (!TryParseGuid(ruleModel.RuleId, out var ruleGuid)) { return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") }); } var entity = ToRuleEntity(ruleModel); var existing = await repository.GetByIdAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false); if (existing is null) { await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); } else { await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); } return CreatedJson(BuildResourceLocation(apiBasePath, "rules", ruleModel.RuleId), ruleModel); }) .WithName("NotifyUpsertRule") .WithDescription(_t("notify.rules.upsert_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, IRuleRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(ruleId, out var ruleGuid)) { return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") }); } var deleted = await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false); return deleted ? Results.NoContent() : Results.NotFound(); }) .WithName("NotifyDeleteRule") .WithDescription(_t("notify.rules.delete_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/channels", async (IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var channels = await repository.GetAllAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false); return JsonResponse(channels.Select(ToNotifyChannel)); }) .WithName("NotifyListChannels") .WithDescription(_t("notify.channels.list_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapGet("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(channelId, out var id)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false); return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel)); }) .WithName("NotifyGetChannel") .WithDescription(_t("notify.channels.get_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } NotifyChannel channelModel; try { channelModel = service.UpgradeChannel(body); } catch (Exception ex) when (ex is System.Text.Json.JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or FormatException or NotSupportedException) { return Results.BadRequest(new { error = _t("notify.error.channel_payload_invalid", ex.Message) }); } if (!string.Equals(channelModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") }); } if (!TryParseGuid(channelModel.ChannelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } var entity = ToChannelEntity(channelModel); var existing = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false); if (existing is null) { await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); } else { await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); } return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channelModel.ChannelId), channelModel); }) .WithName("NotifyUpsertChannel") .WithDescription(_t("notify.channels.upsert_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapPost("/channels/{channelId}/test", async ( string channelId, ChannelTestSendRequest? request, IChannelRepository repository, INotifyChannelTestService testService, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (request is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } if (!TryParseGuid(channelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken) .ConfigureAwait(false); if (channelEntity is null) { return Results.NotFound(); } var channel = ToNotifyChannel(channelEntity); try { var response = await testService.SendAsync( tenant, channel, request, context.TraceIdentifier, cancellationToken).ConfigureAwait(false); return Results.Accepted(value: response); } catch (ChannelTestSendValidationException ex) { return Results.BadRequest(new { error = ex.Message }); } }) .WithName("NotifyTestChannel") .WithDescription(_t("notify.channels.test_description")) .RequireAuthorization(NotifyPolicies.Operator) .RequireRateLimiting(NotifyRateLimitPolicies.TestSend); apiGroup.MapGet("/channels/{channelId}/health", async ( string channelId, IChannelRepository repository, INotifyChannelHealthService healthService, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(channelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } var channelEntity = await repository.GetByIdAsync(tenant, channelGuid, cancellationToken) .ConfigureAwait(false); if (channelEntity is null) { return Results.NotFound(); } var channel = ToNotifyChannel(channelEntity); var response = await healthService.CheckAsync( tenant, channel, context.TraceIdentifier, cancellationToken).ConfigureAwait(false); return Results.Ok(response); }) .WithName("NotifyGetChannelHealth") .WithDescription("Returns connector diagnostics for the specified channel.") .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapDelete("/channels/{channelId}", async (string channelId, IChannelRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(channelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }) .WithName("NotifyDeleteChannel") .WithDescription(_t("notify.channels.delete_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/templates", async (ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var templates = await repository.ListAsync(tenant, cancellationToken: cancellationToken).ConfigureAwait(false); return JsonResponse(templates.Select(ToNotifyTemplate)); }) .WithName("NotifyListTemplates") .WithDescription(_t("notify.templates.list_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapGet("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(templateId, out var templateGuid)) { return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") }); } var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false); return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template)); }) .WithName("NotifyGetTemplate") .WithDescription(_t("notify.templates.get_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } var templateModel = service.UpgradeTemplate(body); if (!string.Equals(templateModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") }); } if (!TryParseGuid(templateModel.TemplateId, out var templateGuid)) { return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") }); } var entity = ToTemplateEntity(templateModel); var existing = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false); if (existing is null) { await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); } else { await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); } return CreatedJson(BuildResourceLocation(apiBasePath, "templates", templateModel.TemplateId), templateModel); }) .WithName("NotifyUpsertTemplate") .WithDescription(_t("notify.templates.upsert_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapDelete("/templates/{templateId}", async (string templateId, ITemplateRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(templateId, out var templateGuid)) { return Results.BadRequest(new { error = _t("notify.error.template_id_must_be_guid") }); } await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }) .WithName("NotifyDeleteTemplate") .WithDescription(_t("notify.templates.delete_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapPost("/deliveries", async ([FromBody] JsonNode? body, IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } NotifyDelivery delivery; try { delivery = NotifyCanonicalJsonSerializer.Deserialize(body.ToJsonString()); } catch (Exception ex) { return Results.BadRequest(new { error = _t("notify.error.delivery_payload_invalid", ex.Message) }); } if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = _t("notify.error.tenant_mismatch") }); } if (!TryParseGuid(delivery.DeliveryId, out var deliveryId)) { return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") }); } if (!TryParseGuid(delivery.ActionId, out var channelId)) { return Results.BadRequest(new { error = _t("notify.error.action_id_must_be_guid") }); } if (!TryParseGuid(delivery.RuleId, out var ruleId)) { return Results.BadRequest(new { error = _t("notify.error.rule_id_must_be_guid") }); } var entity = ToDeliveryEntity(delivery, deliveryId, channelId, ruleId, body); var saved = await repository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false); return CreatedJson( BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId), ToDeliveryDetail(saved, channelName: null, channelType: null)); }) .WithName("NotifyCreateDelivery") .WithDescription(_t("notify.deliveries.create_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/deliveries", async ( IDeliveryRepository repository, IChannelRepository channelRepository, HttpContext context, [FromQuery] string? status, [FromQuery] string? channelId, [FromQuery] string? eventType, [FromQuery] DateTimeOffset? since, [FromQuery] DateTimeOffset? until, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } DeliveryStatus? statusFilter = null; if (!string.IsNullOrWhiteSpace(status)) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) { return Results.BadRequest(new { error = _t("notify.error.delivery_status_unknown") }); } statusFilter = parsed; } Guid? channelGuid = null; if (!string.IsNullOrWhiteSpace(channelId)) { if (!Guid.TryParse(channelId, out var parsedChannel)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } channelGuid = parsedChannel; } var take = Math.Clamp(limit ?? 100, 1, 500); var skip = Math.Max(0, offset ?? 0); var deliveries = await repository .QueryAsync(tenant, statusFilter, channelGuid, eventType, since, until, take, skip, cancellationToken) .ConfigureAwait(false); var summaries = new List(deliveries.Count); foreach (var delivery in deliveries) { string? channelName = null; string? channelType = null; var channel = await channelRepository.GetByIdAsync(delivery.TenantId, delivery.ChannelId, cancellationToken).ConfigureAwait(false); if (channel is not null) { channelName = channel.Name; channelType = channel.ChannelType.ToString().ToLowerInvariant(); } summaries.Add(ToDeliverySummary(delivery, channelName, channelType)); } var hasMore = deliveries.Count == take; var nextCursor = hasMore ? (skip + deliveries.Count).ToString(CultureInfo.InvariantCulture) : null; return JsonResponse(new { items = summaries, count = summaries.Count, total = summaries.Count, hasMore, nextCursor, continuationToken = nextCursor }); }) .WithName("NotifyListDeliveries") .WithDescription(_t("notify.deliveries.list_description")) .RequireAuthorization(NotifyPolicies.Viewer) .RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory); apiGroup.MapGet("/delivery/stats", async ( IDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var now = DateTimeOffset.UtcNow; var stats = await repository.GetStatsAsync(tenant!, now.AddHours(-24), now, cancellationToken); var totalCompleted = stats.Sent + stats.Delivered + stats.Failed + stats.Bounced; var successCount = stats.Sent + stats.Delivered; var rate = totalCompleted > 0 ? (double)successCount / totalCompleted * 100.0 : 0.0; return Results.Ok(new { totalSent = stats.Sent + stats.Delivered, totalFailed = stats.Failed + stats.Bounced, totalPending = stats.Pending, successRate = Math.Round(rate, 1), windowHours = 24, evaluatedAt = now }); }) .WithName("NotifyDeliveryStats") .WithDescription("Get delivery statistics for the last 24 hours") .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapGet("/deliveries/{deliveryId}", async ( string deliveryId, IDeliveryRepository repository, IChannelRepository channelRepository, HttpContext context, [FromQuery] string? tenant, CancellationToken cancellationToken) => { var headerResolved = TryResolveTenant(context, tenantHeader, out var tenantFromHeader, out var error); var effectiveTenant = tenant ?? tenantFromHeader; if (effectiveTenant is null && !headerResolved) { return error!; } if (effectiveTenant is null) { return Results.BadRequest(new { error = _t("notify.error.tenant_required") }); } if (!TryParseGuid(deliveryId, out var deliveryGuid)) { return Results.BadRequest(new { error = _t("notify.error.delivery_id_must_be_guid") }); } var delivery = await repository.GetByIdAsync(effectiveTenant, deliveryGuid, cancellationToken).ConfigureAwait(false); if (delivery is null) { return Results.NotFound(); } string? channelName = null; string? channelType = null; var channel = await channelRepository.GetByIdAsync(effectiveTenant, delivery.ChannelId, cancellationToken).ConfigureAwait(false); if (channel is not null) { channelName = channel.Name; channelType = channel.ChannelType.ToString().ToLowerInvariant(); } return JsonResponse(ToDeliveryDetail(delivery, channelName, channelType)); }) .WithName("NotifyGetDelivery") .WithDescription(_t("notify.deliveries.get_description")) .RequireAuthorization(NotifyPolicies.Viewer) .RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory); apiGroup.MapPost("/digests", async ([FromBody] DigestUpsertRequest? request, IDigestRepository repository, TimeProvider timeProvider, StellaOps.Determinism.IGuidProvider guidProvider, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (request is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } if (!TryParseGuid(request.ChannelId, out var channelIdGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } if (string.IsNullOrWhiteSpace(request.Recipient)) { return Results.BadRequest(new { error = _t("notify.error.recipient_required") }); } if (string.IsNullOrWhiteSpace(request.DigestKey)) { return Results.BadRequest(new { error = _t("notify.error.digest_key_required") }); } var now = timeProvider.GetUtcNow(); var collectUntil = request.CollectUntil ?? now.AddHours(1); var eventsJson = request.Events?.ToJsonString() ?? "[]"; var digest = new DigestEntity { Id = guidProvider.NewGuid(), TenantId = tenant, ChannelId = channelIdGuid, Recipient = request.Recipient, DigestKey = request.DigestKey, EventCount = request.Events?.Count ?? 0, Events = eventsJson, Status = DigestStatus.Collecting, CollectUntil = collectUntil, SentAt = null, CreatedAt = now, UpdatedAt = now }; var saved = await repository.UpsertAsync(digest, cancellationToken).ConfigureAwait(false); return CreatedJson( BuildResourceLocation(apiBasePath, "digests", request.DigestKey), ToDigestResponse(saved)); }) .WithName("NotifyUpsertDigest") .WithDescription(_t("notify.digests.upsert_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/digests/{actionKey}", async ( string actionKey, [FromQuery] string channelId, [FromQuery] string recipient, IDigestRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(channelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } if (string.IsNullOrWhiteSpace(recipient)) { return Results.BadRequest(new { error = _t("notify.error.recipient_required") }); } var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false); return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest)); }) .WithName("NotifyGetDigest") .WithDescription(_t("notify.digests.get_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapDelete("/digests/{actionKey}", async ( string actionKey, [FromQuery] string channelId, [FromQuery] string recipient, IDigestRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (!TryParseGuid(channelId, out var channelGuid)) { return Results.BadRequest(new { error = _t("notify.error.channel_id_must_be_guid") }); } if (string.IsNullOrWhiteSpace(recipient)) { return Results.BadRequest(new { error = _t("notify.error.recipient_required") }); } var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false); return deleted ? Results.NoContent() : Results.NotFound(); }) .WithName("NotifyDeleteDigest") .WithDescription(_t("notify.digests.delete_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, TimeProvider timeProvider, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } var action = body["action"]?.GetValue(); if (string.IsNullOrWhiteSpace(action)) { return Results.BadRequest(new { error = _t("notify.error.action_required") }); } var entry = new NotifyAuditEntity { TenantId = tenant, UserId = Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) ? userId : null, Action = action, ResourceType = body["entityType"]?.GetValue() ?? string.Empty, ResourceId = body["entityId"]?.GetValue(), Details = body["payload"]?.ToJsonString(), CorrelationId = context.TraceIdentifier, CreatedAt = timeProvider.GetUtcNow() }; var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false); return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id }); }) .WithName("NotifyCreateAuditEntry") .WithDescription(_t("notify.audit.create_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] int? limit, [FromQuery] int? offset, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var results = await repository.ListAsync(tenant, limit ?? 100, offset ?? 0, cancellationToken).ConfigureAwait(false); var payload = results.Select(a => new { a.Id, a.TenantId, a.UserId, a.Action, a.ResourceType, a.ResourceId, a.CorrelationId, a.CreatedAt, Details = string.IsNullOrWhiteSpace(a.Details) ? null : JsonNode.Parse(a.Details) }); return JsonResponse(payload); }) .WithName("NotifyListAuditEntries") .WithDescription(_t("notify.audit.list_description")) .RequireAuthorization(NotifyPolicies.Viewer); apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken).ConfigureAwait(false); return JsonResponse(new { acquired }); }) .WithName("NotifyAcquireLock") .WithDescription(_t("notify.locks.acquire_description")) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, ILockRepository repository, HttpContext context, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error)) { return error!; } var released = await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken).ConfigureAwait(false); return released ? Results.NoContent() : Results.NotFound(); }) .WithName("NotifyReleaseLock") .WithDescription(_t("notify.locks.release_description")) .RequireAuthorization(NotifyPolicies.Operator); } static bool TryParseGuid(string value, out Guid guid) => Guid.TryParse(value, out guid); static RuleEntity ToRuleEntity(NotifyRule rule) { var channelIds = rule.Actions.Select(action => ParseRequiredGuid(action.Channel, "channel")) .ToArray(); var templateId = rule.Actions .Select(action => action.Template) .Where(id => id is not null) .Select(id => Guid.TryParse(id, out var parsed) ? parsed : (Guid?)null) .FirstOrDefault(); return new RuleEntity { Id = ParseRequiredGuid(rule.RuleId, "ruleId"), TenantId = rule.TenantId, Name = rule.Name, Description = rule.Description ?? string.Empty, Enabled = rule.Enabled, Priority = 0, EventTypes = rule.Match.EventKinds.ToArray(), Filter = NotifyCanonicalJsonSerializer.Serialize(rule.Match), ChannelIds = channelIds, TemplateId = templateId, Metadata = NotifyCanonicalJsonSerializer.Serialize(rule), CreatedAt = rule.CreatedAt, UpdatedAt = rule.UpdatedAt }; } static NotifyRule ToNotifyRule(RuleEntity entity) { var match = string.IsNullOrWhiteSpace(entity.Filter) ? NotifyRuleMatch.Create() : NotifyCanonicalJsonSerializer.Deserialize(entity.Filter); var actions = entity.ChannelIds.Select((channelId, index) => NotifyRuleAction.Create( actionId: $"action-{index + 1}", channel: channelId.ToString(), template: entity.TemplateId?.ToString())) .ToImmutableArray(); var metadata = DeserializeMetadata(entity.Metadata); return NotifyRule.Create( entity.Id.ToString(), entity.TenantId, entity.Name, match, actions, enabled: entity.Enabled, description: entity.Description, metadata: metadata, createdAt: entity.CreatedAt, updatedAt: entity.UpdatedAt); } static ChannelEntity ToChannelEntity(NotifyChannel channel) { return new ChannelEntity { Id = ParseRequiredGuid(channel.ChannelId, "channelId"), TenantId = channel.TenantId, Name = channel.Name, ChannelType = ToStorageChannelType(channel.Type), Enabled = channel.Enabled, Config = NotifyCanonicalJsonSerializer.Serialize(channel.Config), Metadata = NotifyCanonicalJsonSerializer.Serialize(channel), CreatedAt = channel.CreatedAt, UpdatedAt = channel.UpdatedAt, CreatedBy = channel.CreatedBy }; } static NotifyChannel ToNotifyChannel(ChannelEntity entity) { var metadataModel = TryDeserialize(entity.Metadata); if (metadataModel is not null) { return metadataModel; } var config = ToNotifyChannelConfig(entity); return NotifyChannel.Create( entity.Id.ToString(), entity.TenantId, entity.Name, ToModelChannelType(entity.ChannelType), config, enabled: entity.Enabled, createdBy: entity.CreatedBy, createdAt: entity.CreatedAt, updatedAt: entity.UpdatedAt); } static TemplateEntity ToTemplateEntity(NotifyTemplate template) { return new TemplateEntity { Id = ParseRequiredGuid(template.TemplateId, "templateId"), TenantId = template.TenantId, Name = template.Key, ChannelType = ToStorageChannelType(template.ChannelType), BodyTemplate = template.Body, Locale = template.Locale, Metadata = NotifyCanonicalJsonSerializer.Serialize(template), CreatedAt = template.CreatedAt, UpdatedAt = template.UpdatedAt, SubjectTemplate = template.Description }; } static NotifyTemplate ToNotifyTemplate(TemplateEntity entity) { var metadataModel = TryDeserialize(entity.Metadata); if (metadataModel is not null) { return metadataModel; } return NotifyTemplate.Create( entity.Id.ToString(), entity.TenantId, ToModelChannelType(entity.ChannelType), entity.Name, entity.Locale, entity.BodyTemplate, description: entity.SubjectTemplate, metadata: DeserializeMetadata(entity.Metadata), createdAt: entity.CreatedAt, updatedAt: entity.UpdatedAt); } static ChannelType ToStorageChannelType(NotifyChannelType type) => type switch { NotifyChannelType.Email => ChannelType.Email, NotifyChannelType.Slack => ChannelType.Slack, NotifyChannelType.Teams => ChannelType.Teams, NotifyChannelType.Webhook => ChannelType.Webhook, NotifyChannelType.PagerDuty => ChannelType.PagerDuty, NotifyChannelType.OpsGenie => ChannelType.OpsGenie, _ => ChannelType.Webhook }; static NotifyChannelType ToModelChannelType(ChannelType type) => type switch { ChannelType.Email => NotifyChannelType.Email, ChannelType.Slack => NotifyChannelType.Slack, ChannelType.Teams => NotifyChannelType.Teams, ChannelType.Webhook => NotifyChannelType.Webhook, ChannelType.PagerDuty => NotifyChannelType.PagerDuty, ChannelType.OpsGenie => NotifyChannelType.OpsGenie, _ => NotifyChannelType.Webhook }; static Guid ParseRequiredGuid(string value, string field) { if (Guid.TryParse(value, out var guid)) { return guid; } throw new InvalidOperationException($"{field} must be a GUID."); } static ImmutableDictionary? DeserializeMetadata(string? json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { var dictionary = JsonSerializer.Deserialize>(json) ?? new Dictionary(); return dictionary.ToImmutableDictionary(StringComparer.Ordinal); } catch { return null; } } static DeliveryEntity ToDeliveryEntity(NotifyDelivery delivery, Guid deliveryId, Guid channelId, Guid ruleId, JsonNode rawPayload) { var correlationId = delivery.Metadata.TryGetValue("traceId", out var trace) ? trace : null; var recipient = delivery.Rendered?.Target; if (string.IsNullOrWhiteSpace(recipient) && delivery.Metadata.TryGetValue("target", out var target)) { recipient = target; } if (string.IsNullOrWhiteSpace(recipient)) { recipient = "unknown"; } return new DeliveryEntity { Id = deliveryId, TenantId = delivery.TenantId, ChannelId = channelId, RuleId = ruleId, TemplateId = null, Status = ToStorageDeliveryStatus(delivery.Status), Recipient = recipient, Subject = delivery.Rendered?.Title, Body = delivery.Rendered?.Body, EventType = delivery.Kind, EventPayload = rawPayload.ToJsonString(), Attempt = delivery.Attempts.Length, MaxAttempts = 3, NextRetryAt = null, ErrorMessage = delivery.StatusReason, ExternalId = null, CorrelationId = correlationId, CreatedAt = delivery.CreatedAt, QueuedAt = null, SentAt = delivery.SentAt, DeliveredAt = delivery.CompletedAt, FailedAt = delivery.Status == NotifyDeliveryStatus.Failed ? delivery.CompletedAt : null }; } static object ToDeliverySummary(DeliveryEntity entity, string? channelName, string? channelType) => new { deliveryId = entity.Id.ToString(), channelId = entity.ChannelId.ToString(), channelName, channelType, eventType = entity.EventType, status = DeliveryStatusToString(entity.Status), attemptCount = entity.Attempt, createdAt = entity.CreatedAt, sentAt = entity.SentAt, latencyMs = entity.SentAt is { } sent ? (long?)(sent - entity.CreatedAt).TotalMilliseconds : null }; static object ToDeliveryDetail(DeliveryEntity entity, string? channelName, string? channelType) => new { deliveryId = entity.Id.ToString(), tenantId = entity.TenantId, channelId = entity.ChannelId.ToString(), channelName, channelType, ruleId = entity.RuleId?.ToString(), eventType = entity.EventType, status = DeliveryStatusToString(entity.Status), subject = entity.Subject, attemptCount = entity.Attempt, attempts = Array.Empty(), createdAt = entity.CreatedAt, sentAt = entity.SentAt, failedAt = entity.FailedAt, errorMessage = entity.ErrorMessage, idempotencyKey = entity.CorrelationId }; static string DeliveryStatusToString(DeliveryStatus status) => status.ToString().ToLowerInvariant(); static DeliveryStatus ToStorageDeliveryStatus(NotifyDeliveryStatus status) => status switch { NotifyDeliveryStatus.Pending => DeliveryStatus.Pending, NotifyDeliveryStatus.Sent => DeliveryStatus.Sent, NotifyDeliveryStatus.Failed => DeliveryStatus.Failed, NotifyDeliveryStatus.Throttled => DeliveryStatus.Queued, NotifyDeliveryStatus.Digested => DeliveryStatus.Delivered, NotifyDeliveryStatus.Dropped => DeliveryStatus.Failed, _ => DeliveryStatus.Pending }; static object ToDigestResponse(DigestEntity entity) => new { id = entity.Id, tenantId = entity.TenantId, channelId = entity.ChannelId, recipient = entity.Recipient, digestKey = entity.DigestKey, eventCount = entity.EventCount, events = string.IsNullOrWhiteSpace(entity.Events) ? new JsonArray() : JsonNode.Parse(entity.Events), status = entity.Status, collectUntil = entity.CollectUntil, sentAt = entity.SentAt, createdAt = entity.CreatedAt, updatedAt = entity.UpdatedAt }; static T? TryDeserialize(string? json) { if (string.IsNullOrWhiteSpace(json)) { return default; } try { return NotifyCanonicalJsonSerializer.Deserialize(json); } catch { return default; } } static NotifyChannelConfig ToNotifyChannelConfig(ChannelEntity entity) { if (string.IsNullOrWhiteSpace(entity.Config)) { return NotifyChannelConfig.Create("inline-default"); } var config = TryDeserialize(entity.Config); if (config is not null) { return config; } try { using var document = JsonDocument.Parse(entity.Config); if (document.RootElement.ValueKind != JsonValueKind.Object) { return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id)); } var root = document.RootElement; var secretRef = GetOptionalString(root, "secretRef"); var target = GetOptionalString(root, "target") ?? GetOptionalString(root, "channel") ?? GetOptionalString(root, "recipient"); var endpoint = GetOptionalString(root, "endpoint") ?? GetOptionalString(root, "webhookUrl") ?? GetOptionalString(root, "url"); var properties = ReadLegacyChannelProperties(root); return NotifyChannelConfig.Create( string.IsNullOrWhiteSpace(secretRef) ? BuildLegacyChannelSecretRef(entity.Id) : secretRef, target, endpoint, properties.Count == 0 ? null : properties); } catch { return NotifyChannelConfig.Create(BuildLegacyChannelSecretRef(entity.Id)); } } static string BuildLegacyChannelSecretRef(Guid channelId) => $"legacy://notify/channels/{channelId:N}"; static string? GetOptionalString(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var property)) { return null; } return property.ValueKind switch { JsonValueKind.Null or JsonValueKind.Undefined => null, JsonValueKind.String => property.GetString(), _ => property.GetRawText() }; } static IReadOnlyList> ReadLegacyChannelProperties(JsonElement root) { var properties = new Dictionary(StringComparer.Ordinal); if (root.TryGetProperty("properties", out var embeddedProperties) && embeddedProperties.ValueKind == JsonValueKind.Object) { foreach (var property in embeddedProperties.EnumerateObject()) { properties[property.Name] = GetJsonPropertyValue(property.Value); } } foreach (var property in root.EnumerateObject()) { if (IsCanonicalChannelConfigField(property.Name)) { continue; } properties[property.Name] = GetJsonPropertyValue(property.Value); } return properties.ToArray(); } static bool IsCanonicalChannelConfigField(string propertyName) => propertyName.Equals("secretRef", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("target", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("endpoint", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("properties", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("limits", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("channel", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("recipient", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("webhookUrl", StringComparison.OrdinalIgnoreCase) || propertyName.Equals("url", StringComparison.OrdinalIgnoreCase); static string GetJsonPropertyValue(JsonElement element) => element.ValueKind == JsonValueKind.String ? element.GetString() ?? string.Empty : element.GetRawText(); static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error) { // Delegate to unified StellaOps tenant resolver (claims + canonical headers + legacy headers) if (StellaOpsTenantResolver.TryResolveTenantId(context, out var resolvedTenant, out var resolverError)) { tenant = resolvedTenant; error = null; return true; } // Fall back to legacy configurable header for backward compatibility if (context.Request.Headers.TryGetValue(tenantHeader, out var header) && !string.IsNullOrWhiteSpace(header)) { tenant = header.ToString().Trim().ToLowerInvariant(); error = null; return true; } tenant = string.Empty; error = Results.BadRequest(new { error = $"Tenant is required. Provide via stellaops:tenant claim, X-StellaOps-Tenant header, or {tenantHeader} header.", error_code = resolverError ?? "tenant_missing" }); return false; } static string BuildResourceLocation(string basePath, params string[] segments) { if (segments.Length == 0) { return basePath; } var builder = new StringBuilder(basePath); foreach (var segment in segments) { builder.Append('/'); builder.Append(Uri.EscapeDataString(segment)); } return builder.ToString(); } static IResult JsonResponse(T value, int statusCode = StatusCodes.Status200OK, string? location = null) { var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); return new JsonHttpResult(payload, statusCode, location); } static IResult CreatedJson(string location, T value) => JsonResponse(value, StatusCodes.Status201Created, location); static IResult Normalize(JsonNode? body, Func upgrade) { if (body is null) { return Results.BadRequest(new { error = _t("notify.error.request_body_required") }); } try { var model = upgrade(body); var json = NotifyCanonicalJsonSerializer.Serialize(model); return Results.Content(json, "application/json"); } catch (Exception ex) { return Results.BadRequest(new { error = ex.Message }); } } static bool HasScope(ClaimsPrincipal principal, string scope) { if (principal is null || string.IsNullOrWhiteSpace(scope)) { return false; } foreach (var claim in principal.Claims) { if (!string.Equals(claim.Type, "scope", StringComparison.OrdinalIgnoreCase) && !string.Equals(claim.Type, "http://schemas.microsoft.com/identity/claims/scope", StringComparison.OrdinalIgnoreCase)) { continue; } if (string.IsNullOrWhiteSpace(claim.Value)) { continue; } var values = claim.Value.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var value in values) { if (string.Equals(value, scope, StringComparison.OrdinalIgnoreCase)) { return true; } } } return false; } static LogEventLevel MapLogLevel(string configuredLevel) { return configuredLevel?.ToLowerInvariant() switch { "verbose" => LogEventLevel.Verbose, "debug" => LogEventLevel.Debug, "warning" => LogEventLevel.Warning, "error" => LogEventLevel.Error, "fatal" => LogEventLevel.Fatal, _ => LogEventLevel.Information }; }