Files
git.stella-ops.org/src/Notify/StellaOps.Notify.WebService/Program.cs
master 9eec100204 refactor(notify): merge Notifier WebService into Notify WebService
- Delete dead Notify Worker (NoOp handler)
- Move 51 source files (endpoints, contracts, services, compat stores)
- Transform namespaces from Notifier.WebService to Notify.WebService
- Update DI registrations, WebSocket support, v2 endpoint mapping
- Comment out notifier-web in compose, update gateway routes
- Update architecture docs, port registry, rollout matrix
- Notifier Worker stays as separate delivery engine container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:17:13 +03:00

1907 lines
76 KiB
C#

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>(
NotifyWebServiceOptions.SectionName,
(opts, _) =>
{
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
NotifyWebServiceOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<NotifyWebServiceOptions>()
.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<StellaOps.Determinism.IGuidProvider, StellaOps.Determinism.SystemGuidProvider>();
builder.Services.AddSingleton<ServiceStatus>();
builder.Services.AddSingleton<NotifySchemaMigrationService>();
// 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<INotifyPluginRegistry, NotifyPluginRegistry>();
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
// =========================================================================
// Notifier v2 DI registrations (merged from Notifier WebService)
// =========================================================================
builder.Services.AddSingleton<ICryptoHmac, DefaultCryptoHmac>();
// Core correlation engine registrations required by incident and escalation flows.
builder.Services.AddCorrelationServices(builder.Configuration);
// Rule evaluation + simulation services power /api/v2/simulate* endpoints.
builder.Services.AddSingleton<StellaOps.Notify.Engine.INotifyRuleEvaluator, StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator>();
SimulationServiceExtensions.AddSimulationServices(builder.Services, builder.Configuration);
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
// In-memory storage for Notifier v2 endpoints (fully qualified to avoid ambiguity with Notify.Persistence types)
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyChannelRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyRuleRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyTemplateRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyDeliveryRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyAuditRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLockRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyInboxRepository, InMemoryInboxStore>();
builder.Services.AddSingleton<StellaOps.Notifier.Worker.Storage.INotifyLocalizationRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyPackApprovalRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryPackApprovalRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyThrottleConfigRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOperatorOverrideRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOperatorOverrideRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyQuietHoursRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryQuietHoursRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyMaintenanceWindowRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryMaintenanceWindowRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyEscalationPolicyRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryEscalationPolicyRepository>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Storage.Compat.INotifyOnCallScheduleRepository, StellaOps.Notify.WebService.Storage.Compat.InMemoryOnCallScheduleRepository>();
// Correlation suppression services
builder.Services.Configure<SuppressionAuditOptions>(builder.Configuration.GetSection(SuppressionAuditOptions.SectionName));
builder.Services.Configure<OperatorOverrideOptions>(builder.Configuration.GetSection(OperatorOverrideOptions.SectionName));
builder.Services.AddSingleton<ISuppressionAuditLogger, InMemorySuppressionAuditLogger>();
builder.Services.AddSingleton<IThrottleConfigurationService, InMemoryThrottleConfigurationService>();
builder.Services.AddSingleton<IQuietHoursCalendarService, InMemoryQuietHoursCalendarService>();
builder.Services.AddSingleton<IOperatorOverrideService, InMemoryOperatorOverrideService>();
// Template service with enhanced renderer (worker contracts)
builder.Services.AddTemplateServices(options =>
{
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
if (!string.IsNullOrWhiteSpace(provenanceUrl))
{
options.ProvenanceBaseUrl = provenanceUrl;
}
});
// Localization resolver with fallback chain
builder.Services.AddSingleton<StellaOps.Notify.Models.ILocalizationResolver, StellaOps.Notify.WebService.Services.DefaultLocalizationResolver>();
// Security services (ack tokens, webhook, HTML sanitizer, tenant isolation)
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
// Observability, dead-letter, and retention services
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
// Escalation and on-call services
builder.Services.AddEscalationServices(builder.Configuration);
// Storm breaker services
builder.Services.AddStormBreakerServices(builder.Configuration);
// Additional security services (signing, webhook validation)
builder.Services.AddNotifierSecurityServices(builder.Configuration);
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
// Notifier WebService template/renderer services
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateService, StellaOps.Notify.WebService.Services.NotifyTemplateService>();
builder.Services.AddSingleton<StellaOps.Notify.WebService.Services.INotifyTemplateRenderer, StellaOps.Notify.WebService.Services.AdvancedTemplateRenderer>();
// Notifier authorization policies
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyViewer, StellaOpsScopes.NotifyViewer);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyOperator, StellaOpsScopes.NotifyOperator);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyAdmin, StellaOpsScopes.NotifyAdmin);
options.AddStellaOpsScopePolicy(NotifierPolicies.NotifyEscalate, StellaOpsScopes.NotifyEscalate);
});
builder.Services.AddHealthChecks();
// =========================================================================
ConfigureAuthentication(builder, bootstrapOptions, builder.Configuration);
ConfigureRateLimiting(builder, bootstrapOptions);
builder.Services.AddEndpointsApiExplorer();
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "notify",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(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<ServiceStatus>();
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().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<bool?>("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<bool?>("notify:authority:allowAnonymousFallback") ?? options.Authority.AllowAnonymousFallback;
if (allowAnonymous)
{
builder.Services.AddAuthentication(authOptions =>
{
authOptions.DefaultAuthenticateScheme = AllowAllAuthenticationHandler.SchemeName;
authOptions.DefaultChallengeScheme = AllowAllAuthenticationHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, AllowAllAuthenticationHandler>(
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<string[]>() ?? 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<NotifyWebServiceOptions, NotifyWebServiceOptions.RateLimitPolicyOptions> policySelector,
string prefix)
{
rateLimiterOptions.AddPolicy(policyName, httpContext =>
{
var opts = httpContext.RequestServices.GetRequiredService<IOptions<NotifyWebServiceOptions>>().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<INotifyPluginRegistry>();
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<IOptions<NotifyWebServiceOptions>>().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<NotifyDelivery>(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<DeliveryStatus>(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<object>(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<string>();
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>() ?? string.Empty,
ResourceId = body["entityId"]?.GetValue<string>(),
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<NotifyRuleMatch>(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<NotifyChannel>(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<NotifyTemplate>(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<string, string>? DeserializeMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var dictionary = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
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<object>(),
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<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return NotifyCanonicalJsonSerializer.Deserialize<T>(json);
}
catch
{
return default;
}
}
static NotifyChannelConfig ToNotifyChannelConfig(ChannelEntity entity)
{
if (string.IsNullOrWhiteSpace(entity.Config))
{
return NotifyChannelConfig.Create("inline-default");
}
var config = TryDeserialize<NotifyChannelConfig>(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<KeyValuePair<string, string>> ReadLegacyChannelProperties(JsonElement root)
{
var properties = new Dictionary<string, string>(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>(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<T>(string location, T value)
=> JsonResponse(value, StatusCodes.Status201Created, location);
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> 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
};
}