up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -0,0 +1,137 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}

View File

@@ -0,0 +1,143 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}

View File

@@ -0,0 +1,305 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -12,7 +12,11 @@ using Microsoft.Extensions.Hosting;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.WebService.Services;
using StellaOps.Notifier.WebService.Setup;
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.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
@@ -53,6 +57,20 @@ builder.Services.AddSingleton<ILocalizationResolver, DefaultLocalizationResolver
builder.Services.Configure<StormBreakerConfig>(builder.Configuration.GetSection("notifier:stormBreaker"));
builder.Services.AddSingleton<IStormBreaker, DefaultStormBreaker>();
// 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, DefaultWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, DefaultTenantIsolationValidator>();
// 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>();
builder.Services.AddHealthChecks();
var app = builder.Build();
@@ -2165,6 +2183,712 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
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 = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { comment = request?.Comment, metadata = request?.Metadata }))
};
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,
ValidateHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new ValidateHtmlResponse
{
IsSafe = true,
Issues = []
});
}
var result = htmlSanitizer.Validate(request.Html);
return Results.Ok(new ValidateHtmlResponse
{
IsSafe = result.IsSafe,
Issues = result.Issues.Select(i => new HtmlIssue
{
Type = i.Type.ToString(),
Description = i.Description,
Element = i.ElementName,
Attribute = i.AttributeName
}).ToArray(),
Stats = result.Stats is not null ? new HtmlStats
{
CharacterCount = result.Stats.CharacterCount,
ElementCount = result.Stats.ElementCount,
MaxDepth = result.Stats.MaxDepth,
LinkCount = result.Stats.LinkCount,
ImageCount = result.Stats.ImageCount
} : null
});
});
app.MapPost("/api/v2/notify/security/html/sanitize", (
HttpContext context,
SanitizeHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new SanitizeHtmlResponse
{
SanitizedHtml = string.Empty,
WasModified = false
});
}
var options = new HtmlSanitizeOptions
{
AllowDataUrls = request.AllowDataUrls,
AdditionalAllowedTags = request.AdditionalAllowedTags?.ToHashSet()
};
var sanitized = htmlSanitizer.Sanitize(request.Html, options);
return Results.Ok(new 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", (
HttpContext context,
ITenantIsolationValidator isolationValidator,
int? limit) =>
{
var violations = isolationValidator.GetRecentViolations(limit ?? 100);
return Results.Ok(new { items = violations, count = violations.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,
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 RetryDeadLetterResponse
{
Results = results.Select(r => new 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
});
});
app.MapGet("/.well-known/openapi", (HttpContext context) =>
{
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
@@ -2178,6 +2902,7 @@ info:
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: {}
@@ -2195,6 +2920,23 @@ paths:
/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: {}
""";
return Results.Text(stub, "application/yaml", Encoding.UTF8);