using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Globalization; using System.Security.Claims; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Serilog; using Serilog.Events; using StellaOps.Auth.ServerIntegration; using StellaOps.Configuration; using System.Collections.Immutable; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Postgres; using StellaOps.Notify.Storage.Postgres.Models; using StellaOps.Notify.Storage.Postgres.Repositories; using StellaOps.Notify.WebService.Diagnostics; using StellaOps.Notify.WebService.Extensions; using StellaOps.Notify.WebService.Hosting; using StellaOps.Notify.WebService.Options; using StellaOps.Notify.WebService.Plugins; using StellaOps.Notify.WebService.Security; using StellaOps.Notify.WebService.Services; using StellaOps.Notify.WebService.Internal; using StellaOps.Plugin.DependencyInjection; using StellaOps.Notify.WebService.Contracts; 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(); // PostgreSQL is the canonical Notify storage; enable Postgres-backed repositories. builder.Services.AddNotifyPostgresStorage(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(); ConfigureAuthentication(builder, bootstrapOptions); ConfigureRateLimiting(builder, bootstrapOptions); builder.Services.AddEndpointsApiExplorer(); var app = builder.Build(); var readyStatus = app.Services.GetRequiredService(); var resolvedOptions = app.Services.GetRequiredService>().Value; await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions); ConfigureRequestPipeline(app, bootstrapOptions); ConfigureEndpoints(app); await app.RunAsync(); static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options) { if (options.Authority.Enabled) { 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 { if (options.Authority.AllowAnonymousFallback) { 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; jwt.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = options.Authority.Issuer, ValidateAudience = options.Authority.Audiences.Count > 0, ValidAudiences = options.Authority.Audiences, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)), 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); var tenantHeader = options.Api.TenantHeader; var limits = options.Api.RateLimits; 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, limits.DeliveryHistory, tenantHeader, "deliveries"); ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, limits.TestSend, tenantHeader, "channel-test"); }); static void ConfigurePolicy( RateLimiterOptions rateLimiterOptions, string policyName, NotifyWebServiceOptions.RateLimitPolicyOptions policy, string tenantHeader, string prefix) { rateLimiterOptions.AddPolicy(policyName, httpContext => { 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) { if (options.Telemetry.EnableRequestLogging) { app.UseSerilogRequestLogging(c => { c.IncludeQueryInRequestPath = true; c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error; }); } app.UseAuthentication(); app.UseRateLimiter(); app.UseAuthorization(); } static void ConfigureEndpoints(WebApplication app) { app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); 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); }); var options = app.Services.GetRequiredService>().Value; var tenantHeader = options.Api.TenantHeader; var apiBasePath = options.Api.BasePath.TrimEnd('/'); var apiGroup = app.MapGroup(options.Api.BasePath); var internalGroup = app.MapGroup(options.Api.InternalBasePath); internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule)) .WithName("notify.rules.normalize") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel)) .WithName("notify.channels.normalize"); internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate)) .WithName("notify.templates.normalize"); 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)); }) .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 = "ruleId must be a GUID." }); } var rule = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false); return rule is null ? Results.NotFound() : JsonResponse(ToNotifyRule(rule)); }) .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 = "Request body is required." }); } var ruleModel = service.UpgradeRule(body); if (!string.Equals(ruleModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = "Tenant mismatch between header and payload." }); } if (!TryParseGuid(ruleModel.RuleId, out var ruleGuid)) { return Results.BadRequest(new { error = "ruleId must be a 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); }) .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 = "ruleId must be a GUID." }); } await repository.DeleteAsync(tenant, ruleGuid, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }) .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)); }) .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 = "channelId must be a GUID." }); } var channel = await repository.GetByIdAsync(tenant, id, cancellationToken).ConfigureAwait(false); return channel is null ? Results.NotFound() : JsonResponse(ToNotifyChannel(channel)); }) .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 = "Request body is required." }); } var channelModel = service.UpgradeChannel(body); if (!string.Equals(channelModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = "Tenant mismatch between header and payload." }); } if (!TryParseGuid(channelModel.ChannelId, out var channelGuid)) { return Results.BadRequest(new { error = "channelId must be a 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); }) .RequireAuthorization(NotifyPolicies.Operator); 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 = "channelId must be a GUID." }); } await repository.DeleteAsync(tenant, channelGuid, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }) .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)); }) .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 = "templateId must be a GUID." }); } var template = await repository.GetByIdAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false); return template is null ? Results.NotFound() : JsonResponse(ToNotifyTemplate(template)); }) .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 = "Request body is required." }); } var templateModel = service.UpgradeTemplate(body); if (!string.Equals(templateModel.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = "Tenant mismatch between header and payload." }); } if (!TryParseGuid(templateModel.TemplateId, out var templateGuid)) { return Results.BadRequest(new { error = "templateId must be a 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); }) .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 = "templateId must be a GUID." }); } await repository.DeleteAsync(tenant, templateGuid, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }) .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 = "Request body is required." }); } NotifyDelivery delivery; try { delivery = NotifyCanonicalJsonSerializer.Deserialize(body.ToJsonString()); } catch (Exception ex) { return Results.BadRequest(new { error = $"Invalid delivery payload: {ex.Message}" }); } if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal)) { return Results.BadRequest(new { error = "Tenant mismatch between header and payload." }); } if (!TryParseGuid(delivery.DeliveryId, out var deliveryId)) { return Results.BadRequest(new { error = "deliveryId must be a GUID." }); } if (!TryParseGuid(delivery.ActionId, out var channelId)) { return Results.BadRequest(new { error = "actionId must be a GUID representing the channel." }); } if (!TryParseGuid(delivery.RuleId, out var ruleId)) { return Results.BadRequest(new { error = "ruleId must be a 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)); }) .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 = "Unknown delivery status." }); } statusFilter = parsed; } Guid? channelGuid = null; if (!string.IsNullOrWhiteSpace(channelId)) { if (!Guid.TryParse(channelId, out var parsedChannel)) { return Results.BadRequest(new { error = "channelId must be a 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 }); }) .RequireAuthorization(NotifyPolicies.Viewer) .RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory); 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 = "Tenant must be provided via header or query string." }); } if (!TryParseGuid(deliveryId, out var deliveryGuid)) { return Results.BadRequest(new { error = "deliveryId must be a 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)); }) .RequireAuthorization(NotifyPolicies.Viewer) .RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory); apiGroup.MapPost("/digests", async ([FromBody] DigestUpsertRequest? request, IDigestRepository repository, 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 = "Request body is required." }); } if (!TryParseGuid(request.ChannelId, out var channelIdGuid)) { return Results.BadRequest(new { error = "channelId must be a GUID." }); } if (string.IsNullOrWhiteSpace(request.Recipient)) { return Results.BadRequest(new { error = "recipient is required." }); } if (string.IsNullOrWhiteSpace(request.DigestKey)) { return Results.BadRequest(new { error = "digestKey is required." }); } var collectUntil = request.CollectUntil ?? DateTimeOffset.UtcNow.AddHours(1); var eventsJson = request.Events?.ToJsonString() ?? "[]"; var digest = new DigestEntity { Id = Guid.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 = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var saved = await repository.UpsertAsync(digest, cancellationToken).ConfigureAwait(false); return CreatedJson( BuildResourceLocation(apiBasePath, "digests", request.DigestKey), ToDigestResponse(saved)); }) .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 = "channelId must be a GUID." }); } if (string.IsNullOrWhiteSpace(recipient)) { return Results.BadRequest(new { error = "recipient is required." }); } var digest = await repository.GetByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false); return digest is null ? Results.NotFound() : JsonResponse(ToDigestResponse(digest)); }) .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 = "channelId must be a GUID." }); } if (string.IsNullOrWhiteSpace(recipient)) { return Results.BadRequest(new { error = "recipient is required." }); } var deleted = await repository.DeleteByKeyAsync(tenant, channelGuid, recipient, actionKey, cancellationToken).ConfigureAwait(false); return deleted ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization(NotifyPolicies.Operator); apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, 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 = "Request body is required." }); } var action = body["action"]?.GetValue(); if (string.IsNullOrWhiteSpace(action)) { return Results.BadRequest(new { error = "Action is 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 = DateTimeOffset.UtcNow }; var id = await repository.CreateAsync(entry, cancellationToken).ConfigureAwait(false); return CreatedJson(BuildResourceLocation(apiBasePath, "audit", id.ToString()), new { id }); }) .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); }) .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 }); }) .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(); }) .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 config = string.IsNullOrWhiteSpace(entity.Config) ? NotifyChannelConfig.Create("inline-default") : NotifyCanonicalJsonSerializer.Deserialize(entity.Config); var metadataModel = TryDeserialize(entity.Metadata); return metadataModel ?? 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 bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error) { if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header)) { tenant = string.Empty; error = Results.BadRequest(new { error = $"{tenantHeader} header is required." }); return false; } tenant = header.ToString().Trim(); error = null; return true; } 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 = "Request body is 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 }; }