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
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:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user