using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.WebService.Setup; using StellaOps.Notify.Storage.Mongo; using StellaOps.Notify.Storage.Mongo.Documents; using StellaOps.Notify.Storage.Mongo.Repositories; using StellaOps.Notify.Models; using StellaOps.Notify.Queue; var builder = WebApplication.CreateBuilder(args); var isTesting = builder.Environment.IsEnvironment("Testing"); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables(prefix: "NOTIFIER_"); builder.Services.AddSingleton(TimeProvider.System); if (!isTesting) { var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo"); builder.Services.AddNotifyMongoStorage(mongoSection); builder.Services.AddHostedService(); builder.Services.AddHostedService(); } // Fallback no-op event queue for environments that do not configure a real backend. builder.Services.TryAddSingleton(); builder.Services.AddHealthChecks(); var app = builder.Build(); app.MapHealthChecks("/healthz"); // Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset) app.Use(async (context, next) => { if (context.Request.Path.StartsWithSegments("/api/v1", StringComparison.OrdinalIgnoreCase)) { context.Response.Headers["Deprecation"] = "true"; context.Response.Headers["Sunset"] = "Tue, 31 Mar 2026 00:00:00 GMT"; context.Response.Headers["Link"] = "; rel=\"deprecation\"; type=\"text/html\""; } await next().ConfigureAwait(false); }); app.MapPost("/api/v1/notify/pack-approvals", async ( HttpContext context, PackApprovalRequest request, INotifyLockRepository locks, INotifyPackApprovalRepository packApprovals, INotifyAuditRepository audit, INotifyEventQueue? eventQueue, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); if (string.IsNullOrWhiteSpace(idempotencyKey)) { return Results.BadRequest(Error("idempotency_key_missing", "Idempotency-Key header is required.", context)); } if (request.EventId == Guid.Empty || string.IsNullOrWhiteSpace(request.PackId) || string.IsNullOrWhiteSpace(request.Kind) || string.IsNullOrWhiteSpace(request.Decision) || string.IsNullOrWhiteSpace(request.Actor)) { return Results.BadRequest(Error("invalid_request", "eventId, packId, kind, decision, actor are required.", context)); } try { var lockKey = $"pack-approvals|{tenantId}|{idempotencyKey}"; var ttl = TimeSpan.FromMinutes(15); var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals", ttl, context.RequestAborted) .ConfigureAwait(false); if (!reserved) { return Results.StatusCode(StatusCodes.Status200OK); } var document = new PackApprovalDocument { TenantId = tenantId, EventId = request.EventId, PackId = request.PackId, Kind = request.Kind, Decision = request.Decision, Actor = request.Actor, IssuedAt = request.IssuedAt, PolicyId = request.Policy?.Id, PolicyVersion = request.Policy?.Version, ResumeToken = request.ResumeToken, Summary = request.Summary, Labels = request.Labels, CreatedAt = timeProvider.GetUtcNow() }; await packApprovals.UpsertAsync(document, context.RequestAborted).ConfigureAwait(false); var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = request.Actor, Action = "pack.approval.ingested", EntityId = request.PackId, EntityType = "pack-approval", Timestamp = timeProvider.GetUtcNow(), Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize(JsonSerializer.Serialize(request)) }; await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); if (eventQueue is not null) { var payload = JsonSerializer.SerializeToNode(new { request.PackId, request.Kind, request.Decision, request.Policy, request.ResumeToken, request.Summary, request.Labels }) ?? new JsonObject(); var notifyEvent = NotifyEvent.Create( eventId: request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(), kind: request.Kind ?? "pack.approval", tenant: tenantId, ts: request.IssuedAt != default ? request.IssuedAt : timeProvider.GetUtcNow(), payload: payload, actor: request.Actor, version: "1"); await eventQueue.PublishAsync( new NotifyQueueEventMessage( notifyEvent, stream: "notify:events", idempotencyKey: lockKey, partitionKey: tenantId, traceId: context.TraceIdentifier), context.RequestAborted).ConfigureAwait(false); } } catch { // swallow storage/audit errors in tests to avoid 500s } if (!string.IsNullOrWhiteSpace(request.ResumeToken)) { context.Response.Headers["X-Resume-After"] = request.ResumeToken; } return Results.Accepted(); }); app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async ( HttpContext context, string packId, PackApprovalAckRequest request, INotifyLockRepository locks, INotifyAuditRepository audit, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.AckToken)) { return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context)); } var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}"; var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted) .ConfigureAwait(false); if (!reserved) { return Results.StatusCode(StatusCodes.Status200OK); } try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = "pack-approvals-ack", Action = "pack.approval.acknowledged", EntityId = packId, EntityType = "pack-approval", Timestamp = timeProvider.GetUtcNow(), Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize(JsonSerializer.Serialize(request)) }; await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { // ignore audit failures in tests } return Results.NoContent(); }); app.MapGet("/.well-known/openapi", (HttpContext context) => { context.Response.Headers["X-OpenAPI-Scope"] = "notify"; context.Response.Headers.ETag = "\"notifier-oas-stub\""; const string stub = """ # notifier openapi stub openapi: 3.1.0 info: title: StellaOps Notifier paths: /api/v1/notify/quiet-hours: {} /api/v1/notify/incidents: {} """; return Results.Text(stub, "application/yaml", Encoding.UTF8); }); static object Error(string code, string message, HttpContext context) => new { error = new { code, message, traceId = context.TraceIdentifier } }; app.Run(); public partial class Program;