Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs

3218 lines
115 KiB
C#

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
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.Services;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.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.WebService.Endpoints;
using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.Worker.Tenancy;
using StellaOps.Notifier.Worker.Templates;
using DeadLetterStatus = StellaOps.Notifier.Worker.DeadLetter.DeadLetterStatus;
using Contracts = StellaOps.Notifier.WebService.Contracts;
using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService;
using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplateRenderer;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Router.AspNet;
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>(TimeProvider.System);
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
// In-memory storage (document store removed)
builder.Services.AddSingleton<INotifyChannelRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyRuleRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyTemplateRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyDeliveryRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyAuditRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyLockRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyInboxRepository, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyLocalizationRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
builder.Services.AddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
// 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<ILocalizationResolver, DefaultLocalizationResolver>();
// Security services (NOTIFY-SVC-40-003)
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 (NOTIFY-SVC-40-004)
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, localization, and fallback services
builder.Services.AddStormBreakerServices(builder.Configuration);
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
builder.Services.AddNotifierSecurityServices(builder.Configuration);
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
builder.Services.AddHealthChecks();
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Notifier:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "notifier",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
// Enable WebSocket support for live incident feed
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
app.MapHealthChecks("/healthz");
// Tenant context middleware (extracts and validates tenant from headers/query)
app.UseTenantContext();
app.TryUseStellaRouter(routerOptions);
// 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"] =
"<https://docs.stellaops.example.com/notify/deprecations>; 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 = (JsonSerializer.SerializeToNode(request) as JsonObject)
};
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/attestation-events", async (
HttpContext context,
AttestationEventRequest request,
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));
}
if (string.IsNullOrWhiteSpace(request.Kind))
{
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
}
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
if (eventQueue is not null)
{
var payload = request.Payload ?? new JsonObject();
var notifyEvent = NotifyEvent.Create(
eventId: eventId,
kind: request.Kind!,
tenant: tenantId,
ts: ts,
payload: payload,
attributes: request.Attributes ?? new Dictionary<string, string>(),
actor: request.Actor,
version: "1");
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
}
await eventQueue.PublishAsync(
new NotifyQueueEventMessage(
notifyEvent,
stream: "notify:events",
idempotencyKey: idempotencyKey,
partitionKey: tenantId,
traceId: context.TraceIdentifier),
context.RequestAborted).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
{
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
}
return Results.Accepted();
});
app.MapPost("/api/v1/notify/risk-events", async (
HttpContext context,
RiskEventRequest request,
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));
}
if (string.IsNullOrWhiteSpace(request.Kind))
{
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
}
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
if (eventQueue is not null)
{
var payload = request.Payload ?? new JsonObject();
var notifyEvent = NotifyEvent.Create(
eventId: eventId,
kind: request.Kind!,
tenant: tenantId,
ts: ts,
payload: payload,
attributes: request.Attributes ?? new Dictionary<string, string>(),
actor: request.Actor,
version: "1");
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
}
await eventQueue.PublishAsync(
new NotifyQueueEventMessage(
notifyEvent,
stream: "notify:events",
idempotencyKey: idempotencyKey,
partitionKey: tenantId,
traceId: context.TraceIdentifier),
context.RequestAborted).ConfigureAwait(false);
}
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);
}
// Use actor from request or fall back to endpoint name
var actor = !string.IsNullOrWhiteSpace(request.Actor) ? request.Actor : "pack-approvals-ack";
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "pack.approval.acknowledged",
EntityId = packId,
EntityType = "pack-approval",
Timestamp = timeProvider.GetUtcNow(),
Payload = (JsonSerializer.SerializeToNode(new
{
request.AckToken,
request.Decision,
request.Comment,
request.Actor
}) as JsonObject)
};
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch
{
// ignore audit failures in tests
}
return Results.NoContent();
});
// =============================================
// Templates API (NOTIFY-SVC-38-003 / 38-004)
// =============================================
#if false
app.MapGet("/api/v2/notify/templates", async (
HttpContext context,
WorkerTemplateService templateService,
string? keyPrefix,
string? locale,
NotifyChannelType? channelType) =>
{
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 templates = await templateService.ListAsync(
tenantId,
new TemplateListOptions
{
KeyPrefix = keyPrefix,
Locale = locale,
ChannelType = channelType
},
context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = templates, count = templates.Count });
});
app.MapGet("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
WorkerTemplateService templateService) =>
{
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 template = await templateService.GetByIdAsync(tenantId, templateId, context.RequestAborted)
.ConfigureAwait(false);
return template is not null
? Results.Ok(template)
: Results.NotFound(Error("not_found", $"Template {templateId} not found.", context));
});
app.MapPut("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
TemplateUpsertRequest request,
WorkerTemplateService templateService) =>
{
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 updatedBy = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(updatedBy))
{
updatedBy = "api";
}
if (string.IsNullOrWhiteSpace(request.Key) || string.IsNullOrWhiteSpace(request.Body))
{
return Results.BadRequest(Error("invalid_request", "key and body are required.", context));
}
var template = NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: request.ChannelType ?? NotifyChannelType.Custom,
key: request.Key,
locale: request.Locale ?? "en-us",
body: request.Body,
renderMode: request.RenderMode ?? NotifyTemplateRenderMode.Markdown,
format: request.Format ?? NotifyDeliveryFormat.Json,
description: request.Description,
metadata: request.Metadata);
var result = await templateService.UpsertAsync(template, updatedBy, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(result);
});
app.MapDelete("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
WorkerTemplateService templateService) =>
{
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
await templateService.DeleteAsync(tenantId, templateId, actor, context.RequestAborted)
.ConfigureAwait(false);
return Results.NoContent();
});
app.MapPost("/api/v2/notify/templates/{templateId}/preview", async (
HttpContext context,
string templateId,
TemplatePreviewRequest request,
WorkerTemplateService templateService,
WorkerTemplateRenderer renderer,
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 template = await templateService.GetByIdAsync(tenantId, templateId, context.RequestAborted)
.ConfigureAwait(false);
if (template is null)
{
return Results.NotFound(Error("not_found", $"Template {templateId} not found.", context));
}
var sampleEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: request.EventKind ?? "sample.event",
tenant: tenantId,
ts: timeProvider.GetUtcNow(),
payload: request.SamplePayload ?? new JsonObject(),
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
actor: "preview",
version: "1");
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new TemplatePreviewResponse
{
RenderedBody = rendered.Body,
RenderedSubject = rendered.Subject,
BodyHash = rendered.BodyHash,
Format = rendered.Format.ToString(),
Warnings = null
});
});
// =============================================
// Rules API (NOTIFY-SVC-38-004)
// =============================================
app.MapGet("/api/v2/notify/rules", async (
HttpContext context,
INotifyRuleRepository ruleRepository) =>
{
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 rules = await ruleRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = rules, count = rules.Count });
});
app.MapGet("/api/v2/notify/rules/{ruleId}", async (
HttpContext context,
string ruleId,
INotifyRuleRepository ruleRepository) =>
{
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 rule = await ruleRepository.GetAsync(tenantId, ruleId, context.RequestAborted).ConfigureAwait(false);
return rule is not null
? Results.Ok(rule)
: Results.NotFound(Error("not_found", $"Rule {ruleId} not found.", context));
});
app.MapPut("/api/v2/notify/rules/{ruleId}", async (
HttpContext context,
string ruleId,
RuleUpsertRequest request,
INotifyRuleRepository ruleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
if (string.IsNullOrWhiteSpace(request.Name) || request.Match is null || request.Actions is null)
{
return Results.BadRequest(Error("invalid_request", "name, match, and actions are required.", context));
}
var rule = NotifyRule.Create(
ruleId: ruleId,
tenantId: tenantId,
name: request.Name,
match: NotifyRuleMatch.Create(eventKinds: request.Match.EventKinds ?? []),
actions: request.Actions.Select(a => NotifyRuleAction.Create(
actionId: a.ActionId ?? Guid.NewGuid().ToString("N"),
channel: a.Channel ?? string.Empty,
template: a.Template ?? string.Empty,
locale: a.Locale,
enabled: a.Enabled)).ToArray(),
enabled: request.Enabled ?? true,
description: request.Description);
await ruleRepository.UpsertAsync(rule, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "rule.upsert",
EntityId = ruleId,
EntityType = "rule",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { ruleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch
{
// Audit failure should not block rule update
}
return Results.Ok(rule);
});
app.MapDelete("/api/v2/notify/rules/{ruleId}", async (
HttpContext context,
string ruleId,
INotifyRuleRepository ruleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
await ruleRepository.DeleteAsync(tenantId, ruleId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "rule.delete",
EntityId = ruleId,
EntityType = "rule",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch
{
// Audit failure should not block rule deletion
}
return Results.NoContent();
});
#endif
// =============================================
// Channels API (NOTIFY-SVC-38-004)
// =============================================
app.MapGet("/api/v2/notify/channels", async (
HttpContext context,
INotifyChannelRepository channelRepository) =>
{
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 channels = await channelRepository.ListAsync(tenantId, cancellationToken: context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = channels, count = channels.Count });
});
app.MapGet("/api/v2/notify/channels/{channelId}", async (
HttpContext context,
string channelId,
INotifyChannelRepository channelRepository) =>
{
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 channel = await channelRepository.GetAsync(tenantId, channelId, context.RequestAborted).ConfigureAwait(false);
return channel is not null
? Results.Ok(channel)
: Results.NotFound(Error("not_found", $"Channel {channelId} not found.", context));
});
app.MapPut("/api/v2/notify/channels/{channelId}", async (
HttpContext context,
string channelId,
ChannelUpsertRequest request,
INotifyChannelRepository channelRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(Error("invalid_request", "name is required.", context));
}
var config = NotifyChannelConfig.Create(
secretRef: request.SecretRef ?? string.Empty,
endpoint: request.Endpoint,
target: request.Target);
var channel = NotifyChannel.Create(
channelId: channelId,
tenantId: tenantId,
name: request.Name,
type: request.Type ?? NotifyChannelType.Custom,
config: config,
description: request.Description);
await channelRepository.UpsertAsync(channel, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "channel.upsert",
EntityId = channelId,
EntityType = "channel",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { channelId, name = request.Name, type = request.Type }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch
{
// Audit failure should not block channel update
}
return Results.Ok(channel);
});
app.MapDelete("/api/v2/notify/channels/{channelId}", async (
HttpContext context,
string channelId,
INotifyChannelRepository channelRepository) =>
{
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));
}
await channelRepository.DeleteAsync(tenantId, channelId, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
// =============================================
// Deliveries API (NOTIFY-SVC-38-004)
// =============================================
app.MapGet("/api/v2/notify/deliveries", async (
HttpContext context,
INotifyDeliveryRepository deliveryRepository,
string? status,
DateTimeOffset? since,
int? limit) =>
{
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 result = await deliveryRepository.QueryAsync(
tenantId: tenantId,
since: since,
status: status,
limit: limit ?? 50,
cancellationToken: context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = result.Items, count = result.Items.Count, continuationToken = result.ContinuationToken });
});
app.MapGet("/api/v2/notify/deliveries/{deliveryId}", async (
HttpContext context,
string deliveryId,
INotifyDeliveryRepository deliveryRepository) =>
{
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 delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false);
return delivery is not null
? Results.Ok(delivery)
: Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
});
// =============================================
// Delivery Retry and Stats (NOTIFY-016)
// =============================================
app.MapPost("/api/v2/notify/deliveries/{deliveryId}/retry", async (
HttpContext context,
string deliveryId,
StellaOps.Notifier.WebService.Contracts.DeliveryRetryRequest? request,
INotifyDeliveryRepository deliveryRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
var delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false);
if (delivery is null)
{
return Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
}
if (delivery.Status == NotifyDeliveryStatus.Sent || delivery.Status == NotifyDeliveryStatus.Delivered)
{
return Results.BadRequest(Error("delivery_already_completed", "Cannot retry a completed delivery.", context));
}
var now = timeProvider.GetUtcNow();
var newAttemptNumber = delivery.Attempts.Length + 1;
// Create new attempt and update delivery to pending status for retry
var newAttempt = new NotifyDeliveryAttempt(now, NotifyDeliveryAttemptStatus.Enqueued);
var updatedDelivery = NotifyDelivery.Create(
delivery.DeliveryId,
delivery.TenantId,
delivery.RuleId,
delivery.ActionId,
delivery.EventId,
delivery.Kind,
NotifyDeliveryStatus.Pending,
"Retry requested",
delivery.Rendered,
delivery.Attempts.Append(newAttempt),
delivery.Metadata,
delivery.CreatedAt);
await deliveryRepository.UpdateAsync(updatedDelivery, context.RequestAborted).ConfigureAwait(false);
// Audit the retry
try
{
await auditRepository.AppendAsync(new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "delivery.retry",
EntityId = deliveryId,
EntityType = "delivery",
Timestamp = now,
Payload = System.Text.Json.JsonSerializer.SerializeToNode(new { deliveryId, reason = request?.Reason, forceChannel = request?.ForceChannel }) as System.Text.Json.Nodes.JsonObject
}, context.RequestAborted).ConfigureAwait(false);
}
catch { /* Ignore audit failures */ }
return Results.Ok(new
{
deliveryId,
retried = true,
newAttemptNumber,
scheduledAt = now.ToString("O"),
message = "Delivery scheduled for retry"
});
});
app.MapGet("/api/v2/notify/deliveries/stats", async (
HttpContext context,
INotifyDeliveryRepository deliveryRepository) =>
{
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 allDeliveries = await deliveryRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
var sent = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered);
var failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed);
var throttled = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Throttled);
var pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending);
var total = sent + failed;
// Calculate average delivery time from attempts that have status codes (indicating completion)
var completedAttempts = allDeliveries
.Where(d => d.Attempts.Length > 0)
.SelectMany(d => d.Attempts)
.Where(a => a.StatusCode.HasValue)
.ToList();
var avgDeliveryTime = completedAttempts.Count > 0 ? 0.0 : 0.0; // Response time not tracked in this model
var byChannel = allDeliveries
.GroupBy(d => d.ActionId)
.ToDictionary(
g => g.Key,
g => new
{
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
});
var byEventKind = allDeliveries
.GroupBy(d => d.Kind)
.ToDictionary(
g => g.Key,
g => new
{
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
});
return Results.Ok(new
{
totalSent = sent,
totalFailed = failed,
totalThrottled = throttled,
totalPending = pending,
avgDeliveryTimeMs = avgDeliveryTime,
successRate = total > 0 ? (double)sent / total * 100 : 0,
period = "day",
byChannel,
byEventKind
});
});
// =============================================
// Simulation API (NOTIFY-SVC-39-003)
// =============================================
app.MapPost("/api/v2/notify/simulate", async (
HttpContext context,
SimulationRunRequest request,
INotifyRuleRepository ruleRepository,
INotifyChannelRepository channelRepository,
INotifyAuditRepository auditRepository,
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 (request.PeriodStart >= request.PeriodEnd)
{
return Results.BadRequest(Error("invalid_period", "PeriodStart must be before PeriodEnd.", context));
}
// Create simulation engine inline (lightweight for API use)
var simulationEngine = new StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine(
ruleRepository,
channelRepository,
auditRepository,
new StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator(),
throttler: null,
quietHoursEvaluator: null,
timeProvider,
Microsoft.Extensions.Logging.Abstractions.NullLogger<StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine>.Instance);
var simulationRequest = new StellaOps.Notifier.Worker.Simulation.NotifySimulationRequest
{
TenantId = tenantId,
PeriodStart = request.PeriodStart,
PeriodEnd = request.PeriodEnd,
RuleIds = request.RuleIds,
EventKinds = request.EventKinds,
MaxEvents = Math.Clamp(request.MaxEvents, 1, 10000),
IncludeNonMatches = request.IncludeNonMatches,
EvaluateThrottling = request.EvaluateThrottling,
EvaluateQuietHours = request.EvaluateQuietHours,
EvaluationTimestamp = request.EvaluationTimestamp
};
var result = await simulationEngine.SimulateAsync(simulationRequest, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(result);
});
app.MapPost("/api/v2/notify/simulate/event", async (
HttpContext context,
SimulateSingleEventRequest request,
INotifyRuleRepository ruleRepository,
INotifyChannelRepository channelRepository,
INotifyAuditRepository auditRepository,
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 (request.EventPayload is null)
{
return Results.BadRequest(Error("invalid_request", "EventPayload is required.", context));
}
var simulationEngine = new StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine(
ruleRepository,
channelRepository,
auditRepository,
new StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator(),
throttler: null,
quietHoursEvaluator: null,
timeProvider,
Microsoft.Extensions.Logging.Abstractions.NullLogger<StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine>.Instance);
var result = await simulationEngine.SimulateSingleEventAsync(
tenantId,
request.EventPayload,
request.RuleIds.IsDefaultOrEmpty ? null : request.RuleIds,
request.EvaluationTimestamp,
context.RequestAborted).ConfigureAwait(false);
return Results.Ok(result);
});
// =============================================
// Quiet Hours API (NOTIFY-SVC-39-004)
// =============================================
app.MapGet("/api/v2/notify/quiet-hours", async (
HttpContext context,
INotifyQuietHoursRepository quietHoursRepository,
string? channelId,
bool? enabledOnly) =>
{
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 schedules = await quietHoursRepository.ListAsync(tenantId, channelId, enabledOnly, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = schedules, count = schedules.Count });
});
app.MapGet("/api/v2/notify/quiet-hours/{scheduleId}", async (
HttpContext context,
string scheduleId,
INotifyQuietHoursRepository quietHoursRepository) =>
{
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 schedule = await quietHoursRepository.GetAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false);
return schedule is not null
? Results.Ok(schedule)
: Results.NotFound(Error("not_found", $"Quiet hours schedule {scheduleId} not found.", context));
});
app.MapPut("/api/v2/notify/quiet-hours/{scheduleId}", async (
HttpContext context,
string scheduleId,
QuietHoursUpsertRequest request,
INotifyQuietHoursRepository quietHoursRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CronExpression) ||
string.IsNullOrWhiteSpace(request.TimeZone) || request.Duration <= TimeSpan.Zero)
{
return Results.BadRequest(Error("invalid_request", "name, cronExpression, timeZone, and positive duration are required.", context));
}
var schedule = StellaOps.Notify.Models.NotifyQuietHoursSchedule.Create(
scheduleId: scheduleId,
tenantId: tenantId,
name: request.Name,
cronExpression: request.CronExpression,
duration: request.Duration,
timeZone: request.TimeZone,
channelId: request.ChannelId,
enabled: request.Enabled ?? true,
description: request.Description,
metadata: request.Metadata,
createdBy: actor);
await quietHoursRepository.UpsertAsync(schedule, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "quiethours.upsert",
EntityId = scheduleId,
EntityType = "quiet-hours",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(schedule);
});
app.MapDelete("/api/v2/notify/quiet-hours/{scheduleId}", async (
HttpContext context,
string scheduleId,
INotifyQuietHoursRepository quietHoursRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await quietHoursRepository.DeleteAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "quiethours.delete",
EntityId = scheduleId,
EntityType = "quiet-hours",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// Maintenance Windows API (NOTIFY-SVC-39-004)
// =============================================
app.MapGet("/api/v2/notify/maintenance-windows", async (
HttpContext context,
INotifyMaintenanceWindowRepository maintenanceRepository,
bool? activeOnly) =>
{
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 windows = await maintenanceRepository.ListAsync(tenantId, activeOnly, DateTimeOffset.UtcNow, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = windows, count = windows.Count });
});
app.MapGet("/api/v2/notify/maintenance-windows/{windowId}", async (
HttpContext context,
string windowId,
INotifyMaintenanceWindowRepository maintenanceRepository) =>
{
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 window = await maintenanceRepository.GetAsync(tenantId, windowId, context.RequestAborted).ConfigureAwait(false);
return window is not null
? Results.Ok(window)
: Results.NotFound(Error("not_found", $"Maintenance window {windowId} not found.", context));
});
app.MapPut("/api/v2/notify/maintenance-windows/{windowId}", async (
HttpContext context,
string windowId,
MaintenanceWindowUpsertRequest request,
INotifyMaintenanceWindowRepository maintenanceRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.Name) || request.EndsAt <= request.StartsAt)
{
return Results.BadRequest(Error("invalid_request", "name is required and endsAt must be after startsAt.", context));
}
var window = StellaOps.Notify.Models.NotifyMaintenanceWindow.Create(
windowId: windowId,
tenantId: tenantId,
name: request.Name,
startsAt: request.StartsAt,
endsAt: request.EndsAt,
suppressNotifications: request.SuppressNotifications ?? true,
reason: request.Reason,
channelIds: request.ChannelIds.IsDefaultOrEmpty ? null : request.ChannelIds,
ruleIds: request.RuleIds.IsDefaultOrEmpty ? null : request.RuleIds,
metadata: request.Metadata,
createdBy: actor);
await maintenanceRepository.UpsertAsync(window, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "maintenance.upsert",
EntityId = windowId,
EntityType = "maintenance-window",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { windowId, name = request.Name, startsAt = request.StartsAt, endsAt = request.EndsAt }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(window);
});
app.MapDelete("/api/v2/notify/maintenance-windows/{windowId}", async (
HttpContext context,
string windowId,
INotifyMaintenanceWindowRepository maintenanceRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await maintenanceRepository.DeleteAsync(tenantId, windowId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "maintenance.delete",
EntityId = windowId,
EntityType = "maintenance-window",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// Throttle Configs API (NOTIFY-SVC-39-004)
// =============================================
app.MapGet("/api/v2/notify/throttle-configs", async (
HttpContext context,
INotifyThrottleConfigRepository throttleConfigRepository) =>
{
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 configs = await throttleConfigRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = configs, count = configs.Count });
});
app.MapGet("/api/v2/notify/throttle-configs/{configId}", async (
HttpContext context,
string configId,
INotifyThrottleConfigRepository throttleConfigRepository) =>
{
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 config = await throttleConfigRepository.GetAsync(tenantId, configId, context.RequestAborted).ConfigureAwait(false);
return config is not null
? Results.Ok(config)
: Results.NotFound(Error("not_found", $"Throttle config {configId} not found.", context));
});
app.MapPut("/api/v2/notify/throttle-configs/{configId}", async (
HttpContext context,
string configId,
ThrottleConfigUpsertRequest request,
INotifyThrottleConfigRepository throttleConfigRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.Name) || request.DefaultWindow <= TimeSpan.Zero)
{
return Results.BadRequest(Error("invalid_request", "name and positive defaultWindow are required.", context));
}
var config = StellaOps.Notify.Models.NotifyThrottleConfig.Create(
configId: configId,
tenantId: tenantId,
name: request.Name,
defaultWindow: request.DefaultWindow,
maxNotificationsPerWindow: request.MaxNotificationsPerWindow,
channelId: request.ChannelId,
isDefault: request.IsDefault ?? false,
enabled: request.Enabled ?? true,
description: request.Description,
metadata: request.Metadata,
createdBy: actor);
await throttleConfigRepository.UpsertAsync(config, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "throttleconfig.upsert",
EntityId = configId,
EntityType = "throttle-config",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { configId, name = request.Name, defaultWindow = request.DefaultWindow.TotalSeconds }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(config);
});
app.MapDelete("/api/v2/notify/throttle-configs/{configId}", async (
HttpContext context,
string configId,
INotifyThrottleConfigRepository throttleConfigRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await throttleConfigRepository.DeleteAsync(tenantId, configId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "throttleconfig.delete",
EntityId = configId,
EntityType = "throttle-config",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// Operator Overrides API (NOTIFY-SVC-39-004)
// =============================================
app.MapGet("/api/v2/notify/overrides", async (
HttpContext context,
INotifyOperatorOverrideRepository overrideRepository,
bool? activeOnly) =>
{
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 overrides = await overrideRepository.ListAsync(tenantId, activeOnly, DateTimeOffset.UtcNow, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = overrides, count = overrides.Count });
});
app.MapGet("/api/v2/notify/overrides/{overrideId}", async (
HttpContext context,
string overrideId,
INotifyOperatorOverrideRepository overrideRepository) =>
{
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 @override = await overrideRepository.GetAsync(tenantId, overrideId, context.RequestAborted).ConfigureAwait(false);
return @override is not null
? Results.Ok(@override)
: Results.NotFound(Error("not_found", $"Operator override {overrideId} not found.", context));
});
app.MapPost("/api/v2/notify/overrides", async (
HttpContext context,
OperatorOverrideCreateRequest request,
INotifyOperatorOverrideRepository overrideRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.OverrideType) || request.ExpiresAt <= timeProvider.GetUtcNow())
{
return Results.BadRequest(Error("invalid_request", "overrideType is required and expiresAt must be in the future.", context));
}
if (!Enum.TryParse<StellaOps.Notify.Models.NotifyOverrideType>(request.OverrideType, ignoreCase: true, out var overrideType))
{
return Results.BadRequest(Error("invalid_request", $"Invalid override type: {request.OverrideType}. Valid types: BypassQuietHours, BypassThrottle, BypassMaintenance, ForceSuppression.", context));
}
var overrideId = Guid.NewGuid().ToString("N");
var @override = StellaOps.Notify.Models.NotifyOperatorOverride.Create(
overrideId: overrideId,
tenantId: tenantId,
overrideType: overrideType,
expiresAt: request.ExpiresAt,
channelId: request.ChannelId,
ruleId: request.RuleId,
reason: request.Reason,
createdBy: actor);
await overrideRepository.UpsertAsync(@override, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "override.create",
EntityId = overrideId,
EntityType = "operator-override",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { overrideId, overrideType = request.OverrideType, expiresAt = request.ExpiresAt, reason = request.Reason }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Created($"/api/v2/notify/overrides/{overrideId}", @override);
});
app.MapDelete("/api/v2/notify/overrides/{overrideId}", async (
HttpContext context,
string overrideId,
INotifyOperatorOverrideRepository overrideRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await overrideRepository.DeleteAsync(tenantId, overrideId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "override.delete",
EntityId = overrideId,
EntityType = "operator-override",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// Escalation Policies API (NOTIFY-SVC-40-001)
// =============================================
app.MapGet("/api/v2/notify/escalation-policies", async (
HttpContext context,
INotifyEscalationPolicyRepository policyRepository) =>
{
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 policies = await policyRepository.ListAsync(tenantId, null, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = policies, count = policies.Count });
});
app.MapGet("/api/v2/notify/escalation-policies/{policyId}", async (
HttpContext context,
string policyId,
INotifyEscalationPolicyRepository policyRepository) =>
{
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 policy = await policyRepository.GetAsync(tenantId, policyId, context.RequestAborted).ConfigureAwait(false);
return policy is not null
? Results.Ok(policy)
: Results.NotFound(Error("not_found", $"Escalation policy {policyId} not found.", context));
});
app.MapPut("/api/v2/notify/escalation-policies/{policyId}", async (
HttpContext context,
string policyId,
EscalationPolicyUpsertRequest request,
INotifyEscalationPolicyRepository policyRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.Name) || request.Levels.IsDefaultOrEmpty)
{
return Results.BadRequest(Error("invalid_request", "name and at least one level are required.", context));
}
var levels = request.Levels.Select(l => NotifyEscalationLevel.Create(
order: l.Order,
escalateAfter: l.EscalateAfter,
targets: l.Targets.IsDefaultOrEmpty
? []
: l.Targets.Select(t => NotifyEscalationTarget.Create(
Enum.TryParse<NotifyEscalationTargetType>(t.Type, ignoreCase: true, out var tt) ? tt : NotifyEscalationTargetType.User,
t.TargetId ?? string.Empty)).ToArray())).ToImmutableArray();
var policy = NotifyEscalationPolicy.Create(
policyId: policyId,
tenantId: tenantId,
name: request.Name,
levels: levels,
repeatCount: request.RepeatCount ?? 0,
enabled: request.Enabled ?? true,
description: request.Description);
await policyRepository.UpsertAsync(policy, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "escalationpolicy.upsert",
EntityId = policyId,
EntityType = "escalation-policy",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { policyId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(policy);
});
app.MapDelete("/api/v2/notify/escalation-policies/{policyId}", async (
HttpContext context,
string policyId,
INotifyEscalationPolicyRepository policyRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await policyRepository.DeleteAsync(tenantId, policyId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "escalationpolicy.delete",
EntityId = policyId,
EntityType = "escalation-policy",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// On-Call Schedules API (NOTIFY-SVC-40-001)
// =============================================
app.MapGet("/api/v2/notify/oncall-schedules", async (
HttpContext context,
INotifyOnCallScheduleRepository scheduleRepository) =>
{
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 schedules = await scheduleRepository.ListAsync(tenantId, null, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = schedules, count = schedules.Count });
});
app.MapGet("/api/v2/notify/oncall-schedules/{scheduleId}", async (
HttpContext context,
string scheduleId,
INotifyOnCallScheduleRepository scheduleRepository) =>
{
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 schedule = await scheduleRepository.GetAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false);
return schedule is not null
? Results.Ok(schedule)
: Results.NotFound(Error("not_found", $"On-call schedule {scheduleId} not found.", context));
});
app.MapPut("/api/v2/notify/oncall-schedules/{scheduleId}", async (
HttpContext context,
string scheduleId,
OnCallScheduleUpsertRequest request,
INotifyOnCallScheduleRepository scheduleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.TimeZone))
{
return Results.BadRequest(Error("invalid_request", "name and timeZone are required.", context));
}
var layers = request.Layers.IsDefaultOrEmpty
? ImmutableArray<NotifyOnCallLayer>.Empty
: request.Layers.Select(l => NotifyOnCallLayer.Create(
layerId: l.LayerId ?? Guid.NewGuid().ToString("N"),
name: l.Name ?? "Unnamed Layer",
priority: l.Priority,
rotationType: NotifyRotationType.Custom,
rotationInterval: l.RotationInterval,
rotationStartsAt: l.RotationStartsAt,
participants: l.Participants.IsDefaultOrEmpty
? null
: l.Participants.Select(p => NotifyOnCallParticipant.Create(
userId: p.UserId ?? string.Empty,
name: p.Name,
email: p.Email,
contactMethods: p.ContactMethods.IsDefaultOrEmpty
? null
: p.ContactMethods.Select(cm => new NotifyContactMethod(
Enum.TryParse<NotifyContactMethodType>(cm.Type, ignoreCase: true, out var cmt) ? cmt : NotifyContactMethodType.Email,
cm.Address ?? string.Empty)))),
restrictions: l.Restrictions is null
? null
: NotifyOnCallRestriction.Create(
Enum.TryParse<NotifyRestrictionType>(l.Restrictions.Type, ignoreCase: true, out var rt) ? rt : NotifyRestrictionType.DailyRestriction,
l.Restrictions.TimeRanges.IsDefaultOrEmpty
? null
: l.Restrictions.TimeRanges.Select(tr => new NotifyTimeRange(tr.DayOfWeek, tr.StartTime, tr.EndTime))))).ToImmutableArray();
var schedule = NotifyOnCallSchedule.Create(
scheduleId: scheduleId,
tenantId: tenantId,
name: request.Name,
timeZone: request.TimeZone,
layers: layers,
enabled: request.Enabled ?? true,
description: request.Description);
await scheduleRepository.UpsertAsync(schedule, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "oncallschedule.upsert",
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(schedule);
});
app.MapDelete("/api/v2/notify/oncall-schedules/{scheduleId}", async (
HttpContext context,
string scheduleId,
INotifyOnCallScheduleRepository scheduleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await scheduleRepository.DeleteAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "oncallschedule.delete",
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
app.MapPost("/api/v2/notify/oncall-schedules/{scheduleId}/overrides", async (
HttpContext context,
string scheduleId,
OnCallOverrideRequest request,
INotifyOnCallScheduleRepository scheduleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.UserId) || request.EndsAt <= request.StartsAt)
{
return Results.BadRequest(Error("invalid_request", "userId is required and endsAt must be after startsAt.", context));
}
var overrideId = Guid.NewGuid().ToString("N");
var @override = NotifyOnCallOverride.Create(
overrideId: overrideId,
userId: request.UserId,
startsAt: request.StartsAt,
endsAt: request.EndsAt,
reason: request.Reason,
createdBy: actor);
await scheduleRepository.AddOverrideAsync(tenantId, scheduleId, @override, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "oncallschedule.override.create",
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, overrideId, userId = request.UserId }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Created($"/api/v2/notify/oncall-schedules/{scheduleId}/overrides/{overrideId}", @override);
});
app.MapDelete("/api/v2/notify/oncall-schedules/{scheduleId}/overrides/{overrideId}", async (
HttpContext context,
string scheduleId,
string overrideId,
INotifyOnCallScheduleRepository scheduleRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await scheduleRepository.RemoveOverrideAsync(tenantId, scheduleId, overrideId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "oncallschedule.override.delete",
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
// =============================================
// In-App Inbox API (NOTIFY-SVC-40-001)
// =============================================
app.MapGet("/api/v2/notify/inbox", async (
HttpContext context,
INotifyInboxRepository inboxRepository,
string? userId,
int? limit) =>
{
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(userId))
{
return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context));
}
var messages = await inboxRepository.GetForUserAsync(tenantId, userId, limit ?? 50, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = messages, count = messages.Count });
});
app.MapGet("/api/v2/notify/inbox/{messageId}", async (
HttpContext context,
string messageId,
INotifyInboxRepository inboxRepository) =>
{
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 message = await inboxRepository.GetAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false);
return message is not null
? Results.Ok(message)
: Results.NotFound(Error("not_found", $"Inbox message {messageId} not found.", context));
});
app.MapPost("/api/v2/notify/inbox/{messageId}/read", async (
HttpContext context,
string messageId,
INotifyInboxRepository inboxRepository) =>
{
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));
}
await inboxRepository.MarkReadAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapPost("/api/v2/notify/inbox/read-all", async (
HttpContext context,
INotifyInboxRepository inboxRepository,
string? userId) =>
{
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(userId))
{
return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context));
}
await inboxRepository.MarkAllReadAsync(tenantId, userId, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapGet("/api/v2/notify/inbox/unread-count", async (
HttpContext context,
INotifyInboxRepository inboxRepository,
string? userId) =>
{
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(userId))
{
return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context));
}
var count = await inboxRepository.GetUnreadCountAsync(tenantId, userId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { unreadCount = count });
});
app.MapDelete("/api/v2/notify/inbox/{messageId}", async (
HttpContext context,
string messageId,
INotifyInboxRepository inboxRepository) =>
{
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));
}
await inboxRepository.DeleteAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
// =============================================
// Localization Bundles API (NOTIFY-SVC-40-002)
// =============================================
app.MapGet("/api/v2/notify/localization/bundles", async (
HttpContext context,
INotifyLocalizationRepository localizationRepository,
string? bundleKey) =>
{
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 bundles = await localizationRepository.ListAsync(tenantId, bundleKey, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = bundles, count = bundles.Count });
});
app.MapGet("/api/v2/notify/localization/bundles/{bundleId}", async (
HttpContext context,
string bundleId,
INotifyLocalizationRepository localizationRepository) =>
{
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 bundle = await localizationRepository.GetAsync(tenantId, bundleId, context.RequestAborted).ConfigureAwait(false);
return bundle is not null
? Results.Ok(bundle)
: Results.NotFound(Error("not_found", $"Localization bundle {bundleId} not found.", context));
});
app.MapPut("/api/v2/notify/localization/bundles/{bundleId}", async (
HttpContext context,
string bundleId,
LocalizationBundleUpsertRequest request,
INotifyLocalizationRepository localizationRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
if (string.IsNullOrWhiteSpace(request.Locale) || string.IsNullOrWhiteSpace(request.BundleKey))
{
return Results.BadRequest(Error("invalid_request", "locale and bundleKey are required.", context));
}
var bundle = NotifyLocalizationBundle.Create(
bundleId: bundleId,
tenantId: tenantId,
locale: request.Locale,
bundleKey: request.BundleKey,
strings: request.Strings,
isDefault: request.IsDefault ?? false,
parentLocale: request.ParentLocale,
description: request.Description,
metadata: request.Metadata,
updatedBy: actor);
await localizationRepository.UpsertAsync(bundle, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "localization.bundle.upsert",
EntityId = bundleId,
EntityType = "localization-bundle",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { bundleId, locale = request.Locale, bundleKey = request.BundleKey }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(bundle);
});
app.MapDelete("/api/v2/notify/localization/bundles/{bundleId}", async (
HttpContext context,
string bundleId,
INotifyLocalizationRepository localizationRepository,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
await localizationRepository.DeleteAsync(tenantId, bundleId, context.RequestAborted).ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "localization.bundle.delete",
EntityId = bundleId,
EntityType = "localization-bundle",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.NoContent();
});
app.MapGet("/api/v2/notify/localization/locales", async (
HttpContext context,
INotifyLocalizationRepository localizationRepository,
string? bundleKey) =>
{
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(bundleKey))
{
return Results.BadRequest(Error("invalid_request", "bundleKey query parameter is required.", context));
}
var locales = await localizationRepository.ListLocalesAsync(tenantId, bundleKey, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { locales, count = locales.Count });
});
app.MapPost("/api/v2/notify/localization/resolve", async (
HttpContext context,
LocalizationResolveRequest request,
ILocalizationResolver localizationResolver) =>
{
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.BundleKey) || request.StringKeys is null || request.StringKeys.Count == 0)
{
return Results.BadRequest(Error("invalid_request", "bundleKey and stringKeys are required.", context));
}
var locale = request.Locale ?? "en-us";
var resolved = await localizationResolver.ResolveBatchAsync(
tenantId, request.BundleKey, request.StringKeys, locale, context.RequestAborted).ConfigureAwait(false);
var strings = resolved.ToDictionary(
kv => kv.Key,
kv => new LocalizedStringResult
{
Value = kv.Value.Value,
ResolvedLocale = kv.Value.ResolvedLocale,
UsedFallback = kv.Value.UsedFallback
});
var response = new LocalizationResolveResponse
{
Strings = strings,
RequestedLocale = locale,
FallbackChain = resolved.Values.FirstOrDefault()?.FallbackChain ?? []
};
return Results.Ok(response);
});
// =============================================
// Storm Breaker API (NOTIFY-SVC-40-002)
// =============================================
app.MapGet("/api/v2/notify/storms", async (
HttpContext context,
IStormBreaker stormBreaker) =>
{
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 storms = await stormBreaker.GetActiveStormsAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = storms, count = storms.Count });
});
app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
HttpContext context,
string stormKey,
IStormBreaker stormBreaker,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, context.RequestAborted).ConfigureAwait(false);
if (summary is null)
{
return Results.NotFound(Error("not_found", $"Storm {stormKey} not found or has no events.", context));
}
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "storm.summary.triggered",
EntityId = summary.SummaryId,
EntityType = "storm-summary",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { stormKey, eventCount = summary.TotalEvents }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(summary);
});
// =============================================
// Security API (NOTIFY-SVC-40-003)
// =============================================
// Acknowledge notification via signed token
app.MapGet("/api/v1/ack/{token}", async (
HttpContext context,
string token,
IAckTokenService ackTokenService,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider) =>
{
var verification = ackTokenService.VerifyToken(token);
if (!verification.IsValid)
{
return Results.BadRequest(new AckResponse
{
Success = false,
Error = verification.FailureReason?.ToString() ?? "Invalid token"
});
}
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = verification.Token!.TenantId,
Actor = "ack-link",
Action = $"delivery.{verification.Token.Action}",
EntityId = verification.Token.DeliveryId,
EntityType = "delivery",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new AckResponse
{
Success = true,
DeliveryId = verification.Token!.DeliveryId,
Action = verification.Token.Action,
ProcessedAt = timeProvider.GetUtcNow()
});
});
app.MapPost("/api/v1/ack/{token}", async (
HttpContext context,
string token,
AckRequest? request,
IAckTokenService ackTokenService,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider) =>
{
var verification = ackTokenService.VerifyToken(token);
if (!verification.IsValid)
{
return Results.BadRequest(new AckResponse
{
Success = false,
Error = verification.FailureReason?.ToString() ?? "Invalid token"
});
}
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = verification.Token!.TenantId,
Actor = "ack-link",
Action = $"delivery.{verification.Token.Action}",
EntityId = verification.Token.DeliveryId,
EntityType = "delivery",
Timestamp = timeProvider.GetUtcNow(),
Payload = JsonSerializer.SerializeToNode(
new { comment = request?.Comment, metadata = request?.Metadata }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new AckResponse
{
Success = true,
DeliveryId = verification.Token!.DeliveryId,
Action = verification.Token.Action,
ProcessedAt = timeProvider.GetUtcNow()
});
});
app.MapPost("/api/v2/notify/security/ack-tokens", (
HttpContext context,
CreateAckTokenRequest request,
IAckTokenService ackTokenService) =>
{
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.DeliveryId) || string.IsNullOrWhiteSpace(request.Action))
{
return Results.BadRequest(Error("invalid_request", "deliveryId and action are required.", context));
}
var expiration = request.ExpirationHours.HasValue
? TimeSpan.FromHours(request.ExpirationHours.Value)
: (TimeSpan?)null;
var token = ackTokenService.CreateToken(
tenantId,
request.DeliveryId,
request.Action,
expiration,
request.Metadata);
return Results.Ok(new CreateAckTokenResponse
{
Token = token.TokenString,
AckUrl = ackTokenService.CreateAckUrl(token),
ExpiresAt = token.ExpiresAt
});
});
app.MapPost("/api/v2/notify/security/ack-tokens/verify", (
HttpContext context,
VerifyAckTokenRequest request,
IAckTokenService ackTokenService) =>
{
if (string.IsNullOrWhiteSpace(request.Token))
{
return Results.BadRequest(Error("invalid_request", "token is required.", context));
}
var verification = ackTokenService.VerifyToken(request.Token);
return Results.Ok(new VerifyAckTokenResponse
{
IsValid = verification.IsValid,
DeliveryId = verification.Token?.DeliveryId,
Action = verification.Token?.Action,
ExpiresAt = verification.Token?.ExpiresAt,
FailureReason = verification.FailureReason?.ToString()
});
});
app.MapPost("/api/v2/notify/security/html/validate", (
HttpContext context,
Contracts.ValidateHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new Contracts.ValidateHtmlResponse
{
IsSafe = true,
Issues = []
});
}
var result = htmlSanitizer.Validate(request.Html);
return Results.Ok(new Contracts.ValidateHtmlResponse
{
IsSafe = result.IsValid,
Issues = result.Errors.Select(i => new Contracts.HtmlIssue
{
Type = i.Type.ToString(),
Description = i.Message
}).Concat(result.Warnings.Select(w => new Contracts.HtmlIssue
{
Type = "Warning",
Description = w
})).ToArray(),
Stats = null
});
});
app.MapPost("/api/v2/notify/security/html/sanitize", (
HttpContext context,
Contracts.SanitizeHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new Contracts.SanitizeHtmlResponse
{
SanitizedHtml = string.Empty,
WasModified = false
});
}
var profile = new SanitizationProfile
{
Name = "api-request",
AllowDataUrls = request.AllowDataUrls,
AllowedTags = request.AdditionalAllowedTags?.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? SanitizationProfile.Basic.AllowedTags,
AllowedAttributes = SanitizationProfile.Basic.AllowedAttributes,
AllowedUrlSchemes = SanitizationProfile.Basic.AllowedUrlSchemes,
MaxContentLength = SanitizationProfile.Basic.MaxContentLength,
MaxNestingDepth = SanitizationProfile.Basic.MaxNestingDepth,
StripComments = SanitizationProfile.Basic.StripComments,
StripScripts = SanitizationProfile.Basic.StripScripts
};
var sanitized = htmlSanitizer.Sanitize(request.Html, profile);
return Results.Ok(new Contracts.SanitizeHtmlResponse
{
SanitizedHtml = sanitized,
WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal)
});
});
app.MapPost("/api/v2/notify/security/webhook/{channelId}/rotate", async (
HttpContext context,
string channelId,
IWebhookSecurityService webhookSecurityService,
INotifyAuditRepository auditRepository,
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 actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
var result = await webhookSecurityService.RotateSecretAsync(tenantId, channelId, context.RequestAborted)
.ConfigureAwait(false);
try
{
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = "webhook.secret.rotated",
EntityId = channelId,
EntityType = "channel",
Timestamp = timeProvider.GetUtcNow()
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
catch { }
return Results.Ok(new RotateWebhookSecretResponse
{
Success = result.Success,
NewSecret = result.NewSecret,
ActiveAt = result.ActiveAt,
OldSecretExpiresAt = result.OldSecretExpiresAt,
Error = result.Error
});
});
app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", (
HttpContext context,
string channelId,
IWebhookSecurityService webhookSecurityService) =>
{
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 maskedSecret = webhookSecurityService.GetMaskedSecret(tenantId, channelId);
return Results.Ok(new { channelId, maskedSecret });
});
app.MapGet("/api/v2/notify/security/isolation/violations", async (
HttpContext context,
ITenantIsolationValidator isolationValidator,
int? limit) =>
{
var violations = await isolationValidator.GetViolationsAsync(
tenantId: null,
since: null,
cancellationToken: context.RequestAborted).ConfigureAwait(false);
var items = violations
.Take(limit.GetValueOrDefault(100))
.ToList();
return Results.Ok(new { items, count = items.Count });
});
// =============================================
// Dead-Letter API (NOTIFY-SVC-40-004)
// =============================================
app.MapPost("/api/v2/notify/dead-letter", async (
HttpContext context,
EnqueueDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
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 enqueueRequest = new DeadLetterEnqueueRequest
{
TenantId = tenantId,
DeliveryId = request.DeliveryId,
EventId = request.EventId,
ChannelId = request.ChannelId,
ChannelType = request.ChannelType,
FailureReason = request.FailureReason,
FailureDetails = request.FailureDetails,
AttemptCount = request.AttemptCount,
LastAttemptAt = request.LastAttemptAt,
Metadata = request.Metadata,
OriginalPayload = request.OriginalPayload
};
var entry = await deadLetterService.EnqueueAsync(enqueueRequest, context.RequestAborted).ConfigureAwait(false);
return Results.Created($"/api/v2/notify/dead-letter/{entry.EntryId}", new DeadLetterEntryResponse
{
EntryId = entry.EntryId,
TenantId = entry.TenantId,
DeliveryId = entry.DeliveryId,
EventId = entry.EventId,
ChannelId = entry.ChannelId,
ChannelType = entry.ChannelType,
FailureReason = entry.FailureReason,
FailureDetails = entry.FailureDetails,
AttemptCount = entry.AttemptCount,
CreatedAt = entry.CreatedAt,
LastAttemptAt = entry.LastAttemptAt,
Status = entry.Status.ToString(),
RetryCount = entry.RetryCount,
LastRetryAt = entry.LastRetryAt,
Resolution = entry.Resolution,
ResolvedBy = entry.ResolvedBy,
ResolvedAt = entry.ResolvedAt
});
});
app.MapGet("/api/v2/notify/dead-letter", async (
HttpContext context,
IDeadLetterService deadLetterService,
string? status,
string? channelId,
string? channelType,
DateTimeOffset? since,
DateTimeOffset? until,
int? limit,
int? offset) =>
{
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 options = new DeadLetterListOptions
{
Status = Enum.TryParse<DeadLetterStatus>(status, true, out var s) ? s : null,
ChannelId = channelId,
ChannelType = channelType,
Since = since,
Until = until,
Limit = limit ?? 50,
Offset = offset ?? 0
};
var entries = await deadLetterService.ListAsync(tenantId, options, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new ListDeadLetterResponse
{
Entries = entries.Select(e => new DeadLetterEntryResponse
{
EntryId = e.EntryId,
TenantId = e.TenantId,
DeliveryId = e.DeliveryId,
EventId = e.EventId,
ChannelId = e.ChannelId,
ChannelType = e.ChannelType,
FailureReason = e.FailureReason,
FailureDetails = e.FailureDetails,
AttemptCount = e.AttemptCount,
CreatedAt = e.CreatedAt,
LastAttemptAt = e.LastAttemptAt,
Status = e.Status.ToString(),
RetryCount = e.RetryCount,
LastRetryAt = e.LastRetryAt,
Resolution = e.Resolution,
ResolvedBy = e.ResolvedBy,
ResolvedAt = e.ResolvedAt
}).ToList(),
TotalCount = entries.Count
});
});
app.MapGet("/api/v2/notify/dead-letter/{entryId}", async (
HttpContext context,
string entryId,
IDeadLetterService deadLetterService) =>
{
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 entry = await deadLetterService.GetAsync(tenantId, entryId, context.RequestAborted).ConfigureAwait(false);
if (entry is null)
{
return Results.NotFound(Error("entry_not_found", $"Dead-letter entry {entryId} not found.", context));
}
return Results.Ok(new DeadLetterEntryResponse
{
EntryId = entry.EntryId,
TenantId = entry.TenantId,
DeliveryId = entry.DeliveryId,
EventId = entry.EventId,
ChannelId = entry.ChannelId,
ChannelType = entry.ChannelType,
FailureReason = entry.FailureReason,
FailureDetails = entry.FailureDetails,
AttemptCount = entry.AttemptCount,
CreatedAt = entry.CreatedAt,
LastAttemptAt = entry.LastAttemptAt,
Status = entry.Status.ToString(),
RetryCount = entry.RetryCount,
LastRetryAt = entry.LastRetryAt,
Resolution = entry.Resolution,
ResolvedBy = entry.ResolvedBy,
ResolvedAt = entry.ResolvedAt
});
});
app.MapPost("/api/v2/notify/dead-letter/retry", async (
HttpContext context,
Contracts.RetryDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
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 results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(new Contracts.RetryDeadLetterResponse
{
Results = results.Select(r => new Contracts.DeadLetterRetryResultItem
{
EntryId = r.EntryId,
Success = r.Success,
Error = r.Error,
RetriedAt = r.RetriedAt,
NewDeliveryId = r.NewDeliveryId
}).ToList(),
SuccessCount = results.Count(r => r.Success),
FailureCount = results.Count(r => !r.Success)
});
});
app.MapPost("/api/v2/notify/dead-letter/{entryId}/resolve", async (
HttpContext context,
string entryId,
ResolveDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
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));
}
await deadLetterService.ResolveAsync(tenantId, entryId, request.Resolution, request.ResolvedBy, context.RequestAborted)
.ConfigureAwait(false);
return Results.NoContent();
});
app.MapGet("/api/v2/notify/dead-letter/stats", async (
HttpContext context,
IDeadLetterService deadLetterService) =>
{
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 stats = await deadLetterService.GetStatsAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new DeadLetterStatsResponse
{
TotalCount = stats.TotalCount,
PendingCount = stats.PendingCount,
RetryingCount = stats.RetryingCount,
RetriedCount = stats.RetriedCount,
ResolvedCount = stats.ResolvedCount,
ExhaustedCount = stats.ExhaustedCount,
ByChannel = stats.ByChannel,
ByReason = stats.ByReason,
OldestEntryAt = stats.OldestEntryAt,
NewestEntryAt = stats.NewestEntryAt
});
});
app.MapPost("/api/v2/notify/dead-letter/purge", async (
HttpContext context,
PurgeDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
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 maxAge = TimeSpan.FromDays(request.MaxAgeDays);
var purgedCount = await deadLetterService.PurgeExpiredAsync(tenantId, maxAge, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(new PurgeDeadLetterResponse { PurgedCount = purgedCount });
});
// =============================================
// Retention Policy API (NOTIFY-SVC-40-004)
// =============================================
app.MapGet("/api/v2/notify/retention/policy", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
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 policy = await retentionService.GetPolicyAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionPolicyResponse
{
TenantId = tenantId,
Policy = new RetentionPolicyDto
{
DeliveryRetentionDays = (int)policy.DeliveryRetention.TotalDays,
AuditRetentionDays = (int)policy.AuditRetention.TotalDays,
DeadLetterRetentionDays = (int)policy.DeadLetterRetention.TotalDays,
StormDataRetentionDays = (int)policy.StormDataRetention.TotalDays,
InboxRetentionDays = (int)policy.InboxRetention.TotalDays,
EventHistoryRetentionDays = (int)policy.EventHistoryRetention.TotalDays,
AutoCleanupEnabled = policy.AutoCleanupEnabled,
CleanupSchedule = policy.CleanupSchedule,
MaxDeletesPerRun = policy.MaxDeletesPerRun,
ExtendResolvedRetention = policy.ExtendResolvedRetention,
ResolvedRetentionMultiplier = policy.ResolvedRetentionMultiplier
}
});
});
app.MapPut("/api/v2/notify/retention/policy", async (
HttpContext context,
UpdateRetentionPolicyRequest request,
IRetentionPolicyService retentionService) =>
{
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 policy = new RetentionPolicy
{
DeliveryRetention = TimeSpan.FromDays(request.Policy.DeliveryRetentionDays),
AuditRetention = TimeSpan.FromDays(request.Policy.AuditRetentionDays),
DeadLetterRetention = TimeSpan.FromDays(request.Policy.DeadLetterRetentionDays),
StormDataRetention = TimeSpan.FromDays(request.Policy.StormDataRetentionDays),
InboxRetention = TimeSpan.FromDays(request.Policy.InboxRetentionDays),
EventHistoryRetention = TimeSpan.FromDays(request.Policy.EventHistoryRetentionDays),
AutoCleanupEnabled = request.Policy.AutoCleanupEnabled,
CleanupSchedule = request.Policy.CleanupSchedule,
MaxDeletesPerRun = request.Policy.MaxDeletesPerRun,
ExtendResolvedRetention = request.Policy.ExtendResolvedRetention,
ResolvedRetentionMultiplier = request.Policy.ResolvedRetentionMultiplier
};
await retentionService.SetPolicyAsync(tenantId, policy, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapPost("/api/v2/notify/retention/cleanup", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
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 result = await retentionService.ExecuteCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionCleanupResponse
{
TenantId = result.TenantId,
Success = result.Success,
Error = result.Error,
ExecutedAt = result.ExecutedAt,
DurationMs = result.Duration.TotalMilliseconds,
Counts = new RetentionCleanupCountsDto
{
Deliveries = result.Counts.Deliveries,
AuditEntries = result.Counts.AuditEntries,
DeadLetterEntries = result.Counts.DeadLetterEntries,
StormData = result.Counts.StormData,
InboxMessages = result.Counts.InboxMessages,
Events = result.Counts.Events,
Total = result.Counts.Total
}
});
});
app.MapGet("/api/v2/notify/retention/cleanup/preview", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
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 preview = await retentionService.PreviewCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new RetentionCleanupPreviewResponse
{
TenantId = preview.TenantId,
PreviewedAt = preview.PreviewedAt,
EstimatedCounts = new RetentionCleanupCountsDto
{
Deliveries = preview.EstimatedCounts.Deliveries,
AuditEntries = preview.EstimatedCounts.AuditEntries,
DeadLetterEntries = preview.EstimatedCounts.DeadLetterEntries,
StormData = preview.EstimatedCounts.StormData,
InboxMessages = preview.EstimatedCounts.InboxMessages,
Events = preview.EstimatedCounts.Events,
Total = preview.EstimatedCounts.Total
},
PolicyApplied = new RetentionPolicyDto
{
DeliveryRetentionDays = (int)preview.PolicyApplied.DeliveryRetention.TotalDays,
AuditRetentionDays = (int)preview.PolicyApplied.AuditRetention.TotalDays,
DeadLetterRetentionDays = (int)preview.PolicyApplied.DeadLetterRetention.TotalDays,
StormDataRetentionDays = (int)preview.PolicyApplied.StormDataRetention.TotalDays,
InboxRetentionDays = (int)preview.PolicyApplied.InboxRetention.TotalDays,
EventHistoryRetentionDays = (int)preview.PolicyApplied.EventHistoryRetention.TotalDays,
AutoCleanupEnabled = preview.PolicyApplied.AutoCleanupEnabled,
CleanupSchedule = preview.PolicyApplied.CleanupSchedule,
MaxDeletesPerRun = preview.PolicyApplied.MaxDeletesPerRun,
ExtendResolvedRetention = preview.PolicyApplied.ExtendResolvedRetention,
ResolvedRetentionMultiplier = preview.PolicyApplied.ResolvedRetentionMultiplier
},
CutoffDates = preview.CutoffDates
});
});
app.MapGet("/api/v2/notify/retention/cleanup/last", async (
HttpContext context,
IRetentionPolicyService retentionService) =>
{
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 execution = await retentionService.GetLastExecutionAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
if (execution is null)
{
return Results.NotFound(Error("no_execution", "No cleanup execution found.", context));
}
return Results.Ok(new RetentionCleanupExecutionResponse
{
ExecutionId = execution.ExecutionId,
TenantId = execution.TenantId,
StartedAt = execution.StartedAt,
CompletedAt = execution.CompletedAt,
Status = execution.Status.ToString(),
Counts = execution.Counts is not null ? new RetentionCleanupCountsDto
{
Deliveries = execution.Counts.Deliveries,
AuditEntries = execution.Counts.AuditEntries,
DeadLetterEntries = execution.Counts.DeadLetterEntries,
StormData = execution.Counts.StormData,
InboxMessages = execution.Counts.InboxMessages,
Events = execution.Counts.Events,
Total = execution.Counts.Total
} : null,
Error = execution.Error
});
});
// v2 REST APIs (/api/v2/notify/... for existing consumers)
app.MapNotifyApiV2();
// v2 REST APIs (/api/v2/... simplified paths)
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();
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: {}
/api/v1/ack/{token}: {}
/api/v2/notify/templates: {}
/api/v2/notify/rules: {}
/api/v2/notify/channels: {}
/api/v2/notify/deliveries: {}
/api/v2/notify/simulate: {}
/api/v2/notify/simulate/event: {}
/api/v2/notify/quiet-hours: {}
/api/v2/notify/maintenance-windows: {}
/api/v2/notify/throttle-configs: {}
/api/v2/notify/overrides: {}
/api/v2/notify/escalation-policies: {}
/api/v2/notify/oncall-schedules: {}
/api/v2/notify/inbox: {}
/api/v2/notify/localization/bundles: {}
/api/v2/notify/localization/locales: {}
/api/v2/notify/localization/resolve: {}
/api/v2/notify/storms: {}
/api/v2/notify/security/ack-tokens: {}
/api/v2/notify/security/ack-tokens/verify: {}
/api/v2/notify/security/html/validate: {}
/api/v2/notify/security/html/sanitize: {}
/api/v2/notify/security/webhook/{channelId}/rotate: {}
/api/v2/notify/security/webhook/{channelId}/secret: {}
/api/v2/notify/security/isolation/violations: {}
/api/v2/notify/dead-letter: {}
/api/v2/notify/dead-letter/{entryId}: {}
/api/v2/notify/dead-letter/retry: {}
/api/v2/notify/dead-letter/{entryId}/resolve: {}
/api/v2/notify/dead-letter/stats: {}
/api/v2/notify/dead-letter/purge: {}
/api/v2/notify/retention/policy: {}
/api/v2/notify/retention/cleanup: {}
/api/v2/notify/retention/cleanup/preview: {}
/api/v2/notify/retention/cleanup/last: {}
/api/v2/rules: {}
/api/v2/templates: {}
/api/v2/incidents: {}
/api/v2/incidents/live: {}
""";
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
}
};
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.Run();
// Make Program class accessible to test projects using WebApplicationFactory
public partial class Program;