search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

View File

@@ -1,42 +1,22 @@
extern alias webservice;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using WebProgram = webservice::Program;
using Xunit;
namespace StellaOps.Notifier.Tests.Endpoints;
public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactory<WebProgram>>
public sealed class NotifyApiEndpointsTests : IClassFixture<NotifierApplicationFactory>
{
private readonly HttpClient _client;
private readonly InMemoryRuleRepository _ruleRepository;
private readonly InMemoryTemplateRepository _templateRepository;
private readonly WebApplicationFactory<WebProgram> _factory;
private readonly NotifierApplicationFactory _factory;
public NotifyApiEndpointsTests(WebApplicationFactory<WebProgram> factory)
public NotifyApiEndpointsTests(NotifierApplicationFactory factory)
{
_ruleRepository = new InMemoryRuleRepository();
_templateRepository = new InMemoryTemplateRepository();
var customFactory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyRuleRepository>(_ruleRepository);
services.AddSingleton<INotifyTemplateRepository>(_templateRepository);
});
builder.UseSetting("Environment", "Testing");
});
_factory = customFactory;
_client = customFactory.CreateClient();
_factory = factory;
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
}
@@ -45,8 +25,12 @@ public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactor
[Fact]
public async Task GetRules_ReturnsEmptyList_WhenNoRules()
{
// Use a unique tenant so other tests' data doesn't interfere
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "empty-rules-tenant");
// Act
var response = await _client.GetAsync("/api/v2/notify/rules", CancellationToken.None);
var response = await client.GetAsync("/api/v2/notify/rules", CancellationToken.None);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -108,7 +92,7 @@ public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactor
channel: "slack:alerts",
template: "tmpl-001")
});
await _ruleRepository.UpsertAsync(rule, CancellationToken.None);
await _factory.RuleRepo.UpsertAsync(rule, CancellationToken.None);
// Act
var response = await _client.GetAsync("/api/v2/notify/rules/rule-get-001", CancellationToken.None);
@@ -146,7 +130,7 @@ public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactor
channel: "slack:alerts",
template: "tmpl-001")
});
await _ruleRepository.UpsertAsync(rule, CancellationToken.None);
await _factory.RuleRepo.UpsertAsync(rule, CancellationToken.None);
// Act
var response = await _client.DeleteAsync("/api/v2/notify/rules/rule-delete-001", CancellationToken.None);
@@ -280,70 +264,4 @@ public sealed class NotifyApiEndpointsTests : IClassFixture<WebApplicationFactor
}
#endregion
#region Test Repositories
private sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly Dictionary<string, NotifyRule> _rules = new();
public Task<NotifyRule> UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
var key = $"{rule.TenantId}:{rule.RuleId}";
_rules[key] = rule;
return Task.FromResult(rule);
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{ruleId}";
return Task.FromResult(_rules.GetValueOrDefault(key));
}
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _rules.Values.Where(r => r.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyRule>>(result);
}
public Task<bool> DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{ruleId}";
var removed = _rules.Remove(key);
return Task.FromResult(removed);
}
}
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
{
private readonly Dictionary<string, NotifyTemplate> _templates = new();
public Task<NotifyTemplate> UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
var key = $"{template.TenantId}:{template.TemplateId}";
_templates[key] = template;
return Task.FromResult(template);
}
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{templateId}";
return Task.FromResult(_templates.GetValueOrDefault(key));
}
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var result = _templates.Values.Where(t => t.TenantId == tenantId).ToList();
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(result);
}
public Task<bool> DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{templateId}";
var removed = _templates.Remove(key);
return Task.FromResult(removed);
}
}
#endregion
}

View File

@@ -1,9 +1,15 @@
extern alias webservice;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Notifier.WebService.Storage.Compat;
using StellaOps.Notifier.Worker.Storage;
@@ -26,6 +32,14 @@ public sealed class NotifierApplicationFactory : WebApplicationFactory<WebProgra
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "http://localhost",
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<INotifyRuleRepository>();
@@ -45,6 +59,50 @@ public sealed class NotifierApplicationFactory : WebApplicationFactory<WebProgra
services.AddSingleton<INotifyAuditRepository>(AuditRepo);
services.AddSingleton<INotifyPackApprovalRepository>(PackRepo);
services.AddSingleton(EventQueue);
// Override authentication with a test handler
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = NotifierTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = NotifierTestAuthHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, NotifierTestAuthHandler>(
NotifierTestAuthHandler.SchemeName, _ => { });
});
}
}
internal sealed class NotifierTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "NotifierTest";
public NotifierTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "test-user"),
new("scope", "notify.viewer notify.operator notify.admin notify.escalate"),
};
// Resolve tenant from headers (matching StellaOpsTenantResolver priority).
// Do NOT default to a tenant — let RequireTenant() reject requests that omit the header.
if (Request.Headers.TryGetValue("X-StellaOps-Tenant", out var canonical) && !string.IsNullOrWhiteSpace(canonical.ToString()))
claims.Add(new Claim("stellaops:tenant", canonical.ToString().Trim()));
else if (Request.Headers.TryGetValue("X-Stella-Tenant", out var legacy) && !string.IsNullOrWhiteSpace(legacy.ToString()))
claims.Add(new Claim("stellaops:tenant", legacy.ToString().Trim()));
else if (Request.Headers.TryGetValue("X-Tenant-Id", out var alt) && !string.IsNullOrWhiteSpace(alt.ToString()))
claims.Add(new Claim("stellaops:tenant", alt.ToString().Trim()));
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -4,6 +4,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.Worker.Escalation;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -27,29 +28,29 @@ public static class EscalationEndpoints
policies.MapGet("/", ListPoliciesAsync)
.WithName("ListEscalationPolicies")
.WithSummary("List escalation policies")
.WithDescription("Returns all escalation policies for the tenant. Policies define the escalation levels, targets, and timing used when an incident is unacknowledged.");
.WithDescription(_t("notifier.escalation_policy.list_description"));
policies.MapGet("/{policyId}", GetPolicyAsync)
.WithName("GetEscalationPolicy")
.WithSummary("Get an escalation policy")
.WithDescription("Returns a single escalation policy by identifier, including all levels and target configurations.");
.WithDescription(_t("notifier.escalation_policy.get_description"));
policies.MapPost("/", CreatePolicyAsync)
.WithName("CreateEscalationPolicy")
.WithSummary("Create an escalation policy")
.WithDescription("Creates a new escalation policy with one or more escalation levels. Each level specifies targets, escalation timeout, and notification mode.")
.WithDescription(_t("notifier.escalation_policy.create_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
policies.MapPut("/{policyId}", UpdatePolicyAsync)
.WithName("UpdateEscalationPolicy")
.WithSummary("Update an escalation policy")
.WithDescription("Updates an existing escalation policy. Changes apply to future escalations; in-flight escalations continue with the previous policy configuration.")
.WithDescription(_t("notifier.escalation_policy.update_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
policies.MapDelete("/{policyId}", DeletePolicyAsync)
.WithName("DeleteEscalationPolicy")
.WithSummary("Delete an escalation policy")
.WithDescription("Deletes an escalation policy. The policy cannot be deleted if it is referenced by active escalations.")
.WithDescription(_t("notifier.escalation_policy.delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
// On-Call Schedules
@@ -62,46 +63,46 @@ public static class EscalationEndpoints
schedules.MapGet("/", ListSchedulesAsync)
.WithName("ListOnCallSchedules")
.WithSummary("List on-call schedules")
.WithDescription("Returns all on-call rotation schedules for the tenant, including layers, rotation intervals, and enabled state.");
.WithDescription(_t("notifier.oncall_schedule.list_description"));
schedules.MapGet("/{scheduleId}", GetScheduleAsync)
.WithName("GetOnCallSchedule")
.WithSummary("Get an on-call schedule")
.WithDescription("Returns a single on-call schedule by identifier, including all rotation layers and user assignments.");
.WithDescription(_t("notifier.oncall_schedule.get_description"));
schedules.MapPost("/", CreateScheduleAsync)
.WithName("CreateOnCallSchedule")
.WithSummary("Create an on-call schedule")
.WithDescription("Creates a new on-call rotation schedule with one or more rotation layers defining users, rotation type, and handoff times.")
.WithDescription(_t("notifier.oncall_schedule.create_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
.WithName("UpdateOnCallSchedule")
.WithSummary("Update an on-call schedule")
.WithDescription("Updates an existing on-call schedule. Current on-call assignments recalculate immediately based on the new configuration.")
.WithDescription(_t("notifier.oncall_schedule.update_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
.WithName("DeleteOnCallSchedule")
.WithSummary("Delete an on-call schedule")
.WithDescription("Deletes an on-call schedule. Escalation policies referencing this schedule will fall back to direct targets.")
.WithDescription(_t("notifier.oncall_schedule.delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
.WithName("GetCurrentOnCall")
.WithSummary("Get current on-call users")
.WithDescription("Returns the users currently on-call for the schedule. Accepts an optional atTime query parameter to evaluate a past or future on-call window.");
.WithDescription(_t("notifier.oncall_schedule.current_description"));
schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
.WithName("CreateOnCallOverride")
.WithSummary("Create an on-call override")
.WithDescription("Creates a time-bounded override placing a specific user on-call for a schedule, superseding the normal rotation for that window.")
.WithDescription(_t("notifier.oncall_schedule.create_override_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
.WithName("DeleteOnCallOverride")
.WithSummary("Delete an on-call override")
.WithDescription("Removes an on-call override, restoring the standard rotation for the schedule.")
.WithDescription(_t("notifier.oncall_schedule.delete_override_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
// Active Escalations
@@ -114,29 +115,29 @@ public static class EscalationEndpoints
escalations.MapGet("/", ListActiveEscalationsAsync)
.WithName("ListActiveEscalations")
.WithSummary("List active escalations")
.WithDescription("Returns all currently active escalations for the tenant, including current level, targets notified, and elapsed time.");
.WithDescription(_t("notifier.escalation.list_description"));
escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
.WithName("GetEscalationState")
.WithSummary("Get escalation state for an incident")
.WithDescription("Returns the current escalation state for a specific incident, including which level is active and when the next escalation is scheduled.");
.WithDescription(_t("notifier.escalation.get_description"));
escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
.WithName("StartEscalation")
.WithSummary("Start escalation for an incident")
.WithDescription("Starts a new escalation for an incident using the specified policy. Returns conflict if an escalation is already active for the incident.")
.WithDescription(_t("notifier.escalation.start_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
.WithName("ManualEscalate")
.WithSummary("Manually escalate to next level")
.WithDescription("Immediately advances the escalation to the next level without waiting for the automatic timeout. An optional reason is recorded in the escalation audit trail.")
.WithDescription(_t("notifier.escalation.manual_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
.WithName("StopEscalation")
.WithSummary("Stop escalation")
.WithDescription("Stops an active escalation for an incident. The stop reason is recorded in the audit trail. On-call targets are not notified after stopping.")
.WithDescription(_t("notifier.escalation.stop_description"))
.RequireAuthorization(NotifierPolicies.NotifyEscalate);
// Ack Bridge
@@ -149,23 +150,23 @@ public static class EscalationEndpoints
ack.MapPost("/", ProcessAckAsync)
.WithName("ProcessAck")
.WithSummary("Process an acknowledgment")
.WithDescription("Processes an acknowledgment for an incident from the API. Stops the escalation if one is active and records the acknowledgment in the audit log.");
.WithDescription(_t("notifier.ack.process_description"));
ack.MapGet("/", ProcessAckLinkAsync)
.WithName("ProcessAckLink")
.WithSummary("Process an acknowledgment link")
.WithDescription("Processes an acknowledgment via a signed one-time link token (e.g., from an email notification). The token is validated for expiry and replay before acknowledgment is recorded.");
.WithDescription(_t("notifier.ack.link_description"));
ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
.WithName("PagerDutyWebhook")
.WithSummary("Process PagerDuty webhook")
.WithDescription("Receives and processes inbound acknowledgment webhooks from PagerDuty. No authentication is required; the request is validated using the PagerDuty webhook signature.")
.WithDescription(_t("notifier.ack.pagerduty_description"))
.AllowAnonymous();
ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
.WithName("OpsGenieWebhook")
.WithSummary("Process OpsGenie webhook")
.WithDescription("Receives and processes inbound acknowledgment webhooks from OpsGenie. No authentication is required; the request is validated using the OpsGenie webhook signature.")
.WithDescription(_t("notifier.ack.opsgenie_description"))
.AllowAnonymous();
return app;
@@ -180,7 +181,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var policies = await policyService.ListPoliciesAsync(tenantId, cancellationToken);
@@ -195,12 +196,12 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var policy = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
return policy is null
? Results.NotFound(new { error = $"Policy '{policyId}' not found." })
? Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) })
: Results.Ok(policy);
}
@@ -214,17 +215,17 @@ public static class EscalationEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Policy name is required." });
return Results.BadRequest(new { error = _t("notifier.error.policy_name_required") });
}
if (request.Levels is null || request.Levels.Count == 0)
{
return Results.BadRequest(new { error = "At least one escalation level is required." });
return Results.BadRequest(new { error = _t("notifier.error.policy_levels_required") });
}
var policy = MapToPolicy(request, tenantId);
@@ -244,13 +245,13 @@ public static class EscalationEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
}
var existing = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new { error = $"Policy '{policyId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
}
var policy = MapToPolicy(request, tenantId) with
@@ -273,11 +274,11 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var deleted = await policyService.DeletePolicyAsync(tenantId, policyId, actor, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Policy '{policyId}' not found." });
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.policy_not_found", policyId) });
}
#endregion
@@ -291,7 +292,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var schedules = await scheduleService.ListSchedulesAsync(tenantId, cancellationToken);
@@ -306,12 +307,12 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var schedule = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
return schedule is null
? Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." })
? Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) })
: Results.Ok(schedule);
}
@@ -325,12 +326,12 @@ public static class EscalationEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Schedule name is required." });
return Results.BadRequest(new { error = _t("notifier.error.schedule_name_required") });
}
var schedule = MapToSchedule(request, tenantId);
@@ -350,13 +351,13 @@ public static class EscalationEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required_stellaops") });
}
var existing = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
}
var schedule = MapToSchedule(request, tenantId) with
@@ -379,11 +380,11 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var deleted = await scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.schedule_not_found", scheduleId) });
}
private static async Task<IResult> GetCurrentOnCallAsync(
@@ -395,7 +396,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var users = await scheduleService.GetCurrentOnCallAsync(tenantId, scheduleId, atTime, cancellationToken);
@@ -412,7 +413,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var @override = new OnCallOverride
@@ -449,11 +450,11 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var deleted = await scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound(new { error = "Override not found." });
return deleted ? Results.NoContent() : Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
}
#endregion
@@ -467,7 +468,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var escalations = await escalationEngine.ListActiveEscalationsAsync(tenantId, cancellationToken);
@@ -482,12 +483,12 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var state = await escalationEngine.GetEscalationStateAsync(tenantId, incidentId, cancellationToken);
return state is null
? Results.NotFound(new { error = $"No escalation found for incident '{incidentId}'." })
? Results.NotFound(new { error = _t("notifier.error.escalation_not_found", incidentId) })
: Results.Ok(state);
}
@@ -500,12 +501,12 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
if (string.IsNullOrWhiteSpace(request.PolicyId))
{
return Results.BadRequest(new { error = "Policy ID is required." });
return Results.BadRequest(new { error = _t("notifier.error.policy_id_required") });
}
try
@@ -529,12 +530,12 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var state = await escalationEngine.EscalateAsync(tenantId, incidentId, request?.Reason, actor, cancellationToken);
return state is null
? Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." })
? Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) })
: Results.Ok(state);
}
@@ -548,7 +549,7 @@ public static class EscalationEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var stopped = await escalationEngine.StopEscalationAsync(
@@ -556,7 +557,7 @@ public static class EscalationEndpoints
return stopped
? Results.NoContent()
: Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." });
: Results.NotFound(new { error = _t("notifier.error.active_escalation_not_found", incidentId) });
}
#endregion
@@ -616,7 +617,7 @@ public static class EscalationEndpoints
var pagerDutyAdapter = adapters.OfType<PagerDutyAdapter>().FirstOrDefault();
if (pagerDutyAdapter is null)
{
return Results.BadRequest(new { error = "PagerDuty integration not configured." });
return Results.BadRequest(new { error = _t("notifier.error.pagerduty_not_configured") });
}
using var reader = new StreamReader(context.Request.Body);
@@ -641,7 +642,7 @@ public static class EscalationEndpoints
var opsGenieAdapter = adapters.OfType<OpsGenieAdapter>().FirstOrDefault();
if (opsGenieAdapter is null)
{
return Results.BadRequest(new { error = "OpsGenie integration not configured." });
return Results.BadRequest(new { error = _t("notifier.error.opsgenie_not_configured") });
}
using var reader = new StreamReader(context.Request.Body);

View File

@@ -7,6 +7,7 @@ using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.Worker.Fallback;
using StellaOps.Notify.Models;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -56,7 +57,7 @@ public static class FallbackEndpoints
})
.WithName("GetFallbackStatistics")
.WithSummary("Gets fallback handling statistics for a tenant")
.WithDescription("Returns aggregate delivery statistics for the tenant including primary success rate, fallback attempt count, fallback success rate, and per-channel failure breakdown over the specified window.");
.WithDescription(_t("notifier.fallback.stats_description"));
// Get fallback chain for a channel
group.MapGet("/chains/{channelType}", async (
@@ -79,7 +80,7 @@ public static class FallbackEndpoints
})
.WithName("GetFallbackChain")
.WithSummary("Gets the fallback chain for a channel type")
.WithDescription("Returns the ordered list of fallback channel types that will be tried when the primary channel fails. If no custom chain is configured, the system default is returned.");
.WithDescription(_t("notifier.fallback.get_chain_description"));
// Set fallback chain for a channel
group.MapPut("/chains/{channelType}", async (
@@ -102,14 +103,14 @@ public static class FallbackEndpoints
return Results.Ok(new
{
message = "Fallback chain updated successfully",
message = _t("notifier.message.fallback_chain_updated"),
primaryChannel = channelType.ToString(),
fallbackChain = chain.Select(c => c.ToString()).ToList()
});
})
.WithName("SetFallbackChain")
.WithSummary("Sets a custom fallback chain for a channel type")
.WithDescription("Creates or replaces the fallback chain for a primary channel type. The chain must reference valid channel types; invalid entries are silently filtered out.")
.WithDescription(_t("notifier.fallback.set_chain_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
// Test fallback resolution
@@ -123,7 +124,7 @@ public static class FallbackEndpoints
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
{
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
return Results.BadRequest(new { error = _t("notifier.error.invalid_fallback_channel", request.FailedChannelType) });
}
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
@@ -159,7 +160,7 @@ public static class FallbackEndpoints
})
.WithName("TestFallback")
.WithSummary("Tests fallback resolution without affecting real deliveries")
.WithDescription("Simulates a channel failure for the specified channel type and returns which fallback channel would be selected next. The simulated delivery state is cleaned up after the test.")
.WithDescription(_t("notifier.fallback.test_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
// Clear delivery state
@@ -173,11 +174,11 @@ public static class FallbackEndpoints
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
return Results.Ok(new { message = _t("notifier.message.delivery_state_cleared", deliveryId) });
})
.WithName("ClearDeliveryFallbackState")
.WithSummary("Clears fallback state for a specific delivery")
.WithDescription("Removes all in-memory fallback tracking state for a delivery ID. Use this to reset a stuck delivery that has exhausted its fallback chain without entering a terminal status.")
.WithDescription(_t("notifier.fallback.clear_delivery_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
return group;

View File

@@ -8,6 +8,7 @@ using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notify.Models;
using System.Text.Json;
using System.Text.Json.Nodes;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -26,23 +27,23 @@ public static class IncidentEndpoints
group.MapGet("/", ListIncidentsAsync)
.WithName("ListIncidents")
.WithSummary("Lists notification incidents (deliveries)")
.WithDescription("Returns a paginated list of notification deliveries for the tenant. Supports filtering by status, event kind, rule ID, time range, and cursor-based pagination.");
.WithDescription(_t("notifier.incident.list2_description"));
group.MapGet("/{deliveryId}", GetIncidentAsync)
.WithName("GetIncident")
.WithSummary("Gets an incident by delivery ID")
.WithDescription("Returns a single delivery record by its identifier, including status, attempt history, and metadata.");
.WithDescription(_t("notifier.incident.get2_description"));
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
.WithName("AcknowledgeIncident")
.WithSummary("Acknowledges an incident")
.WithDescription("Acknowledges or resolves a delivery incident, updating its status and appending an audit entry. Accepts an optional resolution type (resolved, dismissed) and comment.")
.WithDescription(_t("notifier.incident.ack2_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapGet("/stats", GetIncidentStatsAsync)
.WithName("GetIncidentStats")
.WithSummary("Gets incident statistics")
.WithDescription("Returns aggregate delivery counts for the tenant, broken down by status, event kind, and rule ID.");
.WithDescription(_t("notifier.incident.stats_description"));
return app;
}
@@ -60,7 +61,7 @@ public static class IncidentEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
// Query deliveries with filtering
@@ -104,13 +105,13 @@ public static class IncidentEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
}
return Results.Ok(MapToDeliveryResponse(delivery));
@@ -127,7 +128,7 @@ public static class IncidentEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -135,7 +136,7 @@ public static class IncidentEndpoints
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
return Results.NotFound(Error("incident_not_found", _t("notifier.error.incident_not_found", deliveryId), context));
}
// Update delivery status based on acknowledgment
@@ -186,7 +187,7 @@ public static class IncidentEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);

View File

@@ -6,6 +6,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.Worker.Localization;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -57,7 +58,7 @@ public static class LocalizationEndpoints
})
.WithName("ListLocalizationBundles")
.WithSummary("Lists all localization bundles for a tenant")
.WithDescription("Returns all localization bundles for the tenant, including bundle ID, locale, namespace, string count, priority, and enabled state.");
.WithDescription(_t("notifier.localization.list_bundles_description"));
// Get supported locales
group.MapGet("/locales", async (
@@ -78,7 +79,7 @@ public static class LocalizationEndpoints
})
.WithName("GetSupportedLocales")
.WithSummary("Gets all supported locales for a tenant")
.WithDescription("Returns the distinct set of locale codes for which at least one enabled localization bundle exists for the tenant.");
.WithDescription(_t("notifier.localization.get_locales_description"));
// Get bundle contents
group.MapGet("/bundles/{locale}", async (
@@ -101,7 +102,7 @@ public static class LocalizationEndpoints
})
.WithName("GetLocalizationBundle")
.WithSummary("Gets all localized strings for a locale")
.WithDescription("Returns the merged set of all localized strings for the specified locale, combining bundles in priority order.");
.WithDescription(_t("notifier.localization.get_bundle_description"));
// Get single string
group.MapGet("/strings/{key}", async (
@@ -126,7 +127,7 @@ public static class LocalizationEndpoints
})
.WithName("GetLocalizedString")
.WithSummary("Gets a single localized string")
.WithDescription("Resolves a single localized string by key and locale, falling back to en-US if the key is absent in the requested locale.");
.WithDescription(_t("notifier.localization.get_string_description"));
// Format string with parameters
group.MapPost("/strings/{key}/format", async (
@@ -153,7 +154,7 @@ public static class LocalizationEndpoints
})
.WithName("FormatLocalizedString")
.WithSummary("Gets a localized string with parameter substitution")
.WithDescription("Resolves a localized string and applies named parameter substitution using the provided parameters dictionary. Returns the formatted string and the effective locale used.");
.WithDescription(_t("notifier.localization.format_string_description"));
// Create/update bundle
group.MapPut("/bundles", async (
@@ -189,17 +190,17 @@ public static class LocalizationEndpoints
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
{
bundleId = result.BundleId,
message = "Bundle created successfully"
message = _t("notifier.message.bundle_created")
})
: Results.Ok(new
{
bundleId = result.BundleId,
message = "Bundle updated successfully"
message = _t("notifier.message.bundle_updated")
});
})
.WithName("UpsertLocalizationBundle")
.WithSummary("Creates or updates a localization bundle")
.WithDescription("Creates a new localization bundle or replaces an existing one for the given locale and namespace. Returns 201 on creation or 200 on update.")
.WithDescription(_t("notifier.localization.upsert_bundle_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
// Delete bundle
@@ -216,14 +217,14 @@ public static class LocalizationEndpoints
if (!deleted)
{
return Results.NotFound(new { error = $"Bundle '{bundleId}' not found" });
return Results.NotFound(new { error = _t("notifier.error.bundle_not_found", bundleId) });
}
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
return Results.Ok(new { message = _t("notifier.message.bundle_deleted", bundleId) });
})
.WithName("DeleteLocalizationBundle")
.WithSummary("Deletes a localization bundle")
.WithDescription("Permanently removes a localization bundle by bundle ID. Strings in the deleted bundle will no longer be resolved; other bundles for the same locale continue to function.")
.WithDescription(_t("notifier.localization.delete_bundle_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
// Validate bundle
@@ -257,7 +258,7 @@ public static class LocalizationEndpoints
})
.WithName("ValidateLocalizationBundle")
.WithSummary("Validates a localization bundle without saving")
.WithDescription("Validates a localization bundle for structural correctness, required fields, and locale code format without persisting it. Returns isValid, errors, and warnings.");
.WithDescription(_t("notifier.localization.validate_bundle_description"));
return group;
}

View File

@@ -13,6 +13,7 @@ using StellaOps.Notify.Models;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -54,7 +55,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
@@ -62,7 +63,7 @@ public static class NotifyApiEndpoints
return Results.Ok(response);
})
.WithDescription("Returns all alert routing rules for the tenant. Rules define which events trigger notifications, which channels receive them, and any throttle or digest settings applied.");
.WithDescription(_t("notifier.rule.list_description"));
group.MapGet("/rules/{ruleId}", async (
HttpContext context,
@@ -73,18 +74,18 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (rule is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
return Results.Ok(MapRuleToResponse(rule));
})
.WithDescription("Retrieves a single alert routing rule by its identifier. Returns match criteria, actions, throttle settings, and audit metadata.");
.WithDescription(_t("notifier.rule.get_description"));
group.MapPost("/rules", async (
HttpContext context,
@@ -97,7 +98,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -115,7 +116,7 @@ public static class NotifyApiEndpoints
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
})
.WithDescription("Creates a new alert routing rule. The rule specifies event match criteria (kinds, namespaces, severities) and the notification actions to execute. An audit entry is written on creation.")
.WithDescription(_t("notifier.rule.create_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPut("/rules/{ruleId}", async (
@@ -130,13 +131,13 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
var actor = GetActor(context);
@@ -154,7 +155,7 @@ public static class NotifyApiEndpoints
return Results.Ok(MapRuleToResponse(updated));
})
.WithDescription("Updates an existing alert routing rule. Only the provided fields are changed; match criteria, actions, throttle settings, and labels are merged. An audit entry is written on update.")
.WithDescription(_t("notifier.rule.update_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/rules/{ruleId}", async (
@@ -167,13 +168,13 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
var actor = GetActor(context);
@@ -187,7 +188,7 @@ public static class NotifyApiEndpoints
return Results.NoContent();
})
.WithDescription("Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.")
.WithDescription(_t("notifier.rule.delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
}
@@ -205,7 +206,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
NotifyChannelType? channelTypeEnum = null;
@@ -227,7 +228,7 @@ public static class NotifyApiEndpoints
return Results.Ok(response);
})
.WithDescription("Lists all notification templates for the tenant, with optional filtering by key prefix, channel type, and locale. Templates define the rendered message body used by notification rules.");
.WithDescription(_t("notifier.template.list_description"));
group.MapGet("/templates/{templateId}", async (
HttpContext context,
@@ -238,18 +239,18 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
}
return Results.Ok(MapTemplateToResponse(template));
})
.WithDescription("Retrieves a single notification template by its identifier. Returns the template body, channel type, locale, render mode, and audit metadata.");
.WithDescription(_t("notifier.template.get_description"));
group.MapPost("/templates", async (
HttpContext context,
@@ -260,14 +261,14 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
if (!Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var channelType))
{
return Results.BadRequest(Error("invalid_channel_type", $"Invalid channel type: {request.ChannelType}", context));
return Results.BadRequest(Error("invalid_channel_type", _t("notifier.error.invalid_channel_type", request.ChannelType), context));
}
var renderMode = NotifyTemplateRenderMode.Markdown;
@@ -300,7 +301,7 @@ public static class NotifyApiEndpoints
if (!result.Success)
{
return Results.BadRequest(Error("template_validation_failed", result.Error ?? "Validation failed.", context));
return Results.BadRequest(Error("template_validation_failed", result.Error ?? _t("notifier.error.template_validation_failed"), context));
}
var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
@@ -309,7 +310,7 @@ public static class NotifyApiEndpoints
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
: Results.Ok(MapTemplateToResponse(created!));
})
.WithDescription("Creates or updates a notification template. The template body supports Scriban syntax with access to event payload fields. Validation is performed before persisting; an error is returned for invalid syntax.")
.WithDescription(_t("notifier.template.upsert_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/templates/{templateId}", async (
@@ -321,7 +322,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -329,12 +330,12 @@ public static class NotifyApiEndpoints
if (!deleted)
{
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", templateId), context));
}
return Results.NoContent();
})
.WithDescription("Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.")
.WithDescription(_t("notifier.template.delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/templates/preview", async (
@@ -347,7 +348,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
NotifyTemplate? template = null;
@@ -358,7 +359,7 @@ public static class NotifyApiEndpoints
template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template {request.TemplateId} not found.", context));
return Results.NotFound(Error("template_not_found", _t("notifier.error.template_not_found", request.TemplateId), context));
}
}
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
@@ -388,7 +389,7 @@ public static class NotifyApiEndpoints
}
else
{
return Results.BadRequest(Error("template_required", "Either templateId or templateBody must be provided.", context));
return Results.BadRequest(Error("template_required", _t("notifier.error.template_required"), context));
}
var sampleEvent = NotifyEvent.Create(
@@ -412,7 +413,7 @@ public static class NotifyApiEndpoints
Warnings = warnings
});
})
.WithDescription("Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.")
.WithDescription(_t("notifier.template.preview_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/templates/validate", (
@@ -422,7 +423,7 @@ public static class NotifyApiEndpoints
{
if (string.IsNullOrWhiteSpace(request.TemplateBody))
{
return Results.BadRequest(Error("template_body_required", "templateBody is required.", context));
return Results.BadRequest(Error("template_body_required", _t("notifier.error.template_body_required"), context));
}
var result = templateService.Validate(request.TemplateBody);
@@ -434,7 +435,7 @@ public static class NotifyApiEndpoints
warnings = result.Warnings
});
})
.WithDescription("Validates a template body for syntax correctness without persisting it. Returns isValid, a list of errors, and any non-fatal warnings.");
.WithDescription(_t("notifier.template.validate_description"));
}
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
@@ -453,7 +454,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
// For now, return recent deliveries grouped by event kind as "incidents"
@@ -487,7 +488,7 @@ public static class NotifyApiEndpoints
NextCursor = queryResult.ContinuationToken
});
})
.WithDescription("Returns a paginated list of notification incidents for the tenant, grouped by event ID. Supports filtering by status, event kind prefix, time range, and cursor-based pagination.");
.WithDescription(_t("notifier.incident.list_description"));
group.MapPost("/incidents/{incidentId}/ack", async (
HttpContext context,
@@ -499,7 +500,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = request.Actor ?? GetActor(context);
@@ -512,7 +513,7 @@ public static class NotifyApiEndpoints
return Results.NoContent();
})
.WithDescription("Acknowledges an incident, recording the actor and an optional comment in the audit log. Does not stop an active escalation; use the escalation stop endpoint for that.")
.WithDescription(_t("notifier.incident.ack_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/incidents/{incidentId}/resolve", async (
@@ -525,7 +526,7 @@ public static class NotifyApiEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = request.Actor ?? GetActor(context);
@@ -539,7 +540,7 @@ public static class NotifyApiEndpoints
return Results.NoContent();
})
.WithDescription("Marks an incident as resolved, recording the actor, resolution reason, and optional comment in the audit log. Subsequent notifications for this event kind will continue to be processed normally.")
.WithDescription(_t("notifier.incident.resolve_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
}

View File

@@ -7,6 +7,7 @@ using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notifier.Worker.Retention;
using System.Linq;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -28,119 +29,119 @@ public static class ObservabilityEndpoints
group.MapGet("/metrics", GetMetricsSnapshot)
.WithName("GetMetricsSnapshot")
.WithSummary("Gets current metrics snapshot")
.WithDescription("Returns a snapshot of current Notifier service metrics across all tenants, including dispatch rates, error counts, and channel health.");
.WithDescription(_t("notifier.observability.metrics_description"));
group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
.WithName("GetTenantMetrics")
.WithSummary("Gets metrics for a specific tenant")
.WithDescription("Returns a metrics snapshot scoped to a specific tenant, including per-channel delivery rates and recent error totals.");
.WithDescription(_t("notifier.observability.tenant_metrics_description"));
// Dead letter endpoints
group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
.WithName("GetDeadLetters")
.WithSummary("Lists dead letter entries for a tenant")
.WithDescription("Returns paginated dead letter queue entries for the tenant. Dead letters are deliveries that exhausted all retry and fallback attempts.");
.WithDescription(_t("notifier.observability.dead_letters_description"));
group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
.WithName("GetDeadLetterEntry")
.WithSummary("Gets a specific dead letter entry")
.WithDescription("Returns a single dead letter entry by its identifier, including the original payload, error reason, and all previous attempt details.");
.WithDescription(_t("notifier.observability.dead_letter_get_description"));
group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
.WithName("RetryDeadLetter")
.WithSummary("Retries a dead letter entry")
.WithDescription("Re-enqueues a dead letter delivery for reprocessing. The entry is removed from the dead letter queue on success.")
.WithDescription(_t("notifier.observability.dead_letter_retry_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
.WithName("DiscardDeadLetter")
.WithSummary("Discards a dead letter entry")
.WithDescription("Permanently discards a dead letter entry with an optional reason. The entry is removed from the dead letter queue and an audit record is written.")
.WithDescription(_t("notifier.observability.dead_letter_discard_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
.WithName("GetDeadLetterStats")
.WithSummary("Gets dead letter statistics")
.WithDescription("Returns aggregate dead letter statistics for the tenant, including total count, by-channel breakdown, and average age of entries.");
.WithDescription(_t("notifier.observability.dead_letter_stats_description"));
group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
.WithName("PurgeDeadLetters")
.WithSummary("Purges old dead letter entries")
.WithDescription("Removes dead letter entries older than the specified number of days. Returns the count of purged entries.")
.WithDescription(_t("notifier.observability.dead_letter_purge_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
// Chaos testing endpoints
group.MapGet("/chaos/experiments", ListChaosExperiments)
.WithName("ListChaosExperiments")
.WithSummary("Lists chaos experiments")
.WithDescription("Returns all chaos experiments, optionally filtered by status. Chaos experiments inject controlled failures to verify Notifier resilience.");
.WithDescription(_t("notifier.observability.chaos_list_description"));
group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
.WithName("GetChaosExperiment")
.WithSummary("Gets a chaos experiment")
.WithDescription("Returns the configuration and current state of a single chaos experiment by its identifier.");
.WithDescription(_t("notifier.observability.chaos_get_description"));
group.MapPost("/chaos/experiments", StartChaosExperiment)
.WithName("StartChaosExperiment")
.WithSummary("Starts a new chaos experiment")
.WithDescription("Starts a chaos experiment that injects faults into the notification pipeline. Only one experiment per fault type may run concurrently.")
.WithDescription(_t("notifier.observability.chaos_start_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
.WithName("StopChaosExperiment")
.WithSummary("Stops a running chaos experiment")
.WithDescription("Stops a running chaos experiment and removes its fault injection. Normal notification delivery resumes immediately.")
.WithDescription(_t("notifier.observability.chaos_stop_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
.WithName("GetChaosResults")
.WithSummary("Gets chaos experiment results")
.WithDescription("Returns the collected results of a chaos experiment, including injected failure counts, observed retry behavior, and outcome summary.");
.WithDescription(_t("notifier.observability.chaos_results_description"));
// Retention policy endpoints
group.MapGet("/retention/policies", ListRetentionPolicies)
.WithName("ListRetentionPolicies")
.WithSummary("Lists retention policies")
.WithDescription("Returns the active retention policies for the Notifier service, including delivery record TTLs and dead letter purge windows.");
.WithDescription(_t("notifier.observability.retention_list_description"));
group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
.WithName("GetRetentionPolicy")
.WithSummary("Gets a retention policy")
.WithDescription("Returns a single retention policy by its identifier.");
.WithDescription(_t("notifier.observability.retention_get_description"));
group.MapPost("/retention/policies", CreateRetentionPolicy)
.WithName("CreateRetentionPolicy")
.WithSummary("Creates a retention policy")
.WithDescription("Creates a new retention policy. Returns conflict if a policy with the same ID already exists.")
.WithDescription(_t("notifier.observability.retention_create_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
.WithName("UpdateRetentionPolicy")
.WithSummary("Updates a retention policy")
.WithDescription("Updates an existing retention policy. Changes take effect on the next scheduled or manually triggered retention execution.")
.WithDescription(_t("notifier.observability.retention_update_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
.WithName("DeleteRetentionPolicy")
.WithSummary("Deletes a retention policy")
.WithDescription("Deletes a retention policy, reverting the associated data type to the system default retention window.")
.WithDescription(_t("notifier.observability.retention_delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapPost("/retention/execute", ExecuteRetention)
.WithName("ExecuteRetention")
.WithSummary("Executes retention policies")
.WithDescription("Immediately triggers retention cleanup for the specified policy or all policies. Returns the count of records deleted.")
.WithDescription(_t("notifier.observability.retention_execute_description"))
.RequireAuthorization(NotifierPolicies.NotifyAdmin);
group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
.WithName("PreviewRetention")
.WithSummary("Previews retention policy effects")
.WithDescription("Returns the count and identifiers of records that would be deleted if the retention policy were executed now, without deleting anything.");
.WithDescription(_t("notifier.observability.retention_preview_description"));
group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
.WithName("GetRetentionHistory")
.WithSummary("Gets retention execution history")
.WithDescription("Returns the most recent retention execution records for the policy, including run time, records deleted, and any errors encountered.");
.WithDescription(_t("notifier.observability.retention_history_description"));
return endpoints;
}
@@ -186,7 +187,7 @@ public static class ObservabilityEndpoints
var entry = await handler.GetEntryAsync(tenantId, entryId, ct);
if (entry is null)
{
return Results.NotFound(new { error = "Dead letter entry not found" });
return Results.NotFound(new { error = _t("notifier.error.dead_letter_not_found") });
}
return Results.Ok(entry);
}
@@ -262,7 +263,7 @@ public static class ObservabilityEndpoints
var experiment = await runner.GetExperimentAsync(experimentId, ct);
if (experiment is null)
{
return Results.NotFound(new { error = "Experiment not found" });
return Results.NotFound(new { error = _t("notifier.error.experiment_not_found") });
}
return Results.Ok(experiment);
}
@@ -323,7 +324,7 @@ public static class ObservabilityEndpoints
var policy = await service.GetPolicyAsync(policyId, ct);
if (policy is null)
{
return Results.NotFound(new { error = "Policy not found" });
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
}
return Results.Ok(policy);
}
@@ -361,7 +362,7 @@ public static class ObservabilityEndpoints
}
catch (KeyNotFoundException)
{
return Results.NotFound(new { error = "Policy not found" });
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
}
catch (ArgumentException ex)
{
@@ -399,7 +400,7 @@ public static class ObservabilityEndpoints
}
catch (KeyNotFoundException)
{
return Results.NotFound(new { error = "Policy not found" });
return Results.NotFound(new { error = _t("notifier.error.retention_policy_not_found") });
}
}

View File

@@ -4,6 +4,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.Worker.Correlation;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -26,29 +27,29 @@ public static class OperatorOverrideEndpoints
group.MapGet("/", ListOverridesAsync)
.WithName("ListOperatorOverrides")
.WithSummary("List active operator overrides")
.WithDescription("Returns all currently active operator overrides for the tenant, including type (quiet-hours, throttle, maintenance), expiry, and usage counts.");
.WithDescription(_t("notifier.override.list_description"));
group.MapGet("/{overrideId}", GetOverrideAsync)
.WithName("GetOperatorOverride")
.WithSummary("Get an operator override")
.WithDescription("Returns a single operator override by its identifier, including status, remaining duration, and event kind filters.");
.WithDescription(_t("notifier.override.get_description"));
group.MapPost("/", CreateOverrideAsync)
.WithName("CreateOperatorOverride")
.WithSummary("Create an operator override")
.WithDescription("Creates a time-bounded operator override that bypasses quiet hours, throttling, or maintenance windows for the specified event kinds. Requires a reason and duration in minutes.")
.WithDescription(_t("notifier.override.create_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
.WithName("RevokeOperatorOverride")
.WithSummary("Revoke an operator override")
.WithDescription("Immediately revokes an active operator override before its natural expiry. The revocation reason and actor are recorded in the override history.")
.WithDescription(_t("notifier.override.revoke_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/check", CheckOverrideAsync)
.WithName("CheckOperatorOverride")
.WithSummary("Check for applicable override")
.WithDescription("Checks whether any active override applies to a given event kind and optional correlation key. Returns the matched override details and the bypass types it grants.");
.WithDescription(_t("notifier.override.check_description"));
return app;
}
@@ -60,7 +61,7 @@ public static class OperatorOverrideEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
@@ -76,14 +77,14 @@ public static class OperatorOverrideEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
if (@override is null)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.override_not_found", overrideId) });
}
return Results.Ok(MapToApiResponse(@override));
@@ -99,23 +100,23 @@ public static class OperatorOverrideEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
}
var actor = request.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
}
if (string.IsNullOrWhiteSpace(request.Reason))
{
return Results.BadRequest(new { error = "Reason is required." });
return Results.BadRequest(new { error = _t("notifier.error.reason_required") });
}
if (request.DurationMinutes is null or <= 0)
{
return Results.BadRequest(new { error = "Duration must be a positive value in minutes." });
return Results.BadRequest(new { error = _t("notifier.error.duration_required") });
}
var createRequest = new OperatorOverrideCreate
@@ -154,13 +155,13 @@ public static class OperatorOverrideEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var actor = request?.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.actor_required") });
}
var revoked = await overrideService.RevokeOverrideAsync(
@@ -172,7 +173,7 @@ public static class OperatorOverrideEndpoints
if (!revoked)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found or already inactive." });
return Results.NotFound(new { error = _t("notifier.error.override_not_found_or_inactive", overrideId) });
}
return Results.NoContent();
@@ -187,12 +188,12 @@ public static class OperatorOverrideEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
}
var result = await overrideService.CheckOverrideAsync(

View File

@@ -4,6 +4,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.Worker.Correlation;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -26,35 +27,35 @@ public static class QuietHoursEndpoints
group.MapGet("/calendars", ListCalendarsAsync)
.WithName("ListQuietHoursCalendars")
.WithSummary("List all quiet hours calendars")
.WithDescription("Returns all quiet hours calendars for the tenant, including schedules, enabled state, priority, and event kind filters.");
.WithDescription(_t("notifier.quiet_hours.list_description"));
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
.WithName("GetQuietHoursCalendar")
.WithSummary("Get a quiet hours calendar")
.WithDescription("Returns a single quiet hours calendar by its identifier, including all schedule entries and timezone settings.");
.WithDescription(_t("notifier.quiet_hours.get_description"));
group.MapPost("/calendars", CreateCalendarAsync)
.WithName("CreateQuietHoursCalendar")
.WithSummary("Create a quiet hours calendar")
.WithDescription("Creates a new quiet hours calendar defining time windows during which notifications are suppressed. At least one schedule entry is required.")
.WithDescription(_t("notifier.quiet_hours.create_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
.WithName("UpdateQuietHoursCalendar")
.WithSummary("Update a quiet hours calendar")
.WithDescription("Updates an existing quiet hours calendar. Changes take effect immediately for all subsequent notification evaluations.")
.WithDescription(_t("notifier.quiet_hours.update_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
.WithName("DeleteQuietHoursCalendar")
.WithSummary("Delete a quiet hours calendar")
.WithDescription("Permanently removes a quiet hours calendar. Notifications that would have been suppressed by this calendar will resume delivering normally.")
.WithDescription(_t("notifier.quiet_hours.delete_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateQuietHours")
.WithSummary("Evaluate quiet hours")
.WithDescription("Checks whether quiet hours are currently active for the specified event kind. Returns the matched calendar, schedule name, and time when quiet hours end if active.");
.WithDescription(_t("notifier.quiet_hours.evaluate_description"));
return app;
}
@@ -66,7 +67,7 @@ public static class QuietHoursEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
@@ -82,14 +83,14 @@ public static class QuietHoursEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (calendar is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
}
return Results.Ok(MapToApiResponse(calendar));
@@ -105,17 +106,17 @@ public static class QuietHoursEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Calendar name is required." });
return Results.BadRequest(new { error = _t("notifier.error.calendar_name_required") });
}
if (request.Schedules is null || request.Schedules.Count == 0)
{
return Results.BadRequest(new { error = "At least one schedule is required." });
return Results.BadRequest(new { error = _t("notifier.error.calendar_schedules_required") });
}
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
@@ -149,13 +150,13 @@ public static class QuietHoursEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
}
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
}
var calendar = new QuietHoursCalendar
@@ -187,14 +188,14 @@ public static class QuietHoursEndpoints
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_id_missing") });
}
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
return Results.NotFound(new { error = _t("notifier.error.calendar_not_found", calendarId) });
}
return Results.NoContent();
@@ -209,12 +210,12 @@ public static class QuietHoursEndpoints
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
return Results.BadRequest(new { error = _t("notifier.error.tenant_required") });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
return Results.BadRequest(new { error = _t("notifier.error.event_kind_required") });
}
var result = await calendarService.EvaluateAsync(

View File

@@ -10,6 +10,7 @@ using StellaOps.Notify.Models;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -28,29 +29,29 @@ public static class RuleEndpoints
group.MapGet("/", ListRulesAsync)
.WithName("ListRules")
.WithSummary("Lists all rules for a tenant")
.WithDescription("Returns all alert routing rules for the tenant with optional filtering by enabled state, name prefix, and limit. Rules define event match criteria and the notification actions to execute.");
.WithDescription(_t("notifier.rule.list2_description"));
group.MapGet("/{ruleId}", GetRuleAsync)
.WithName("GetRule")
.WithSummary("Gets a rule by ID")
.WithDescription("Returns a single alert routing rule by its identifier, including match criteria, actions, throttle settings, labels, and audit metadata.");
.WithDescription(_t("notifier.rule.get2_description"));
group.MapPost("/", CreateRuleAsync)
.WithName("CreateRule")
.WithSummary("Creates a new rule")
.WithDescription("Creates a new alert routing rule. Returns conflict if a rule with the same ID already exists. An audit entry is written on creation.")
.WithDescription(_t("notifier.rule.create2_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapPut("/{ruleId}", UpdateRuleAsync)
.WithName("UpdateRule")
.WithSummary("Updates an existing rule")
.WithDescription("Updates an existing alert routing rule. Provided fields are merged with the existing rule. An audit entry is written on update.")
.WithDescription(_t("notifier.rule.update2_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
group.MapDelete("/{ruleId}", DeleteRuleAsync)
.WithName("DeleteRule")
.WithSummary("Deletes a rule")
.WithDescription("Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.")
.WithDescription(_t("notifier.rule.delete2_description"))
.RequireAuthorization(NotifierPolicies.NotifyOperator);
return app;
@@ -66,7 +67,7 @@ public static class RuleEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var allRules = await rules.ListAsync(tenantId, context.RequestAborted);
@@ -100,13 +101,13 @@ public static class RuleEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (rule is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
return Results.Ok(MapToResponse(rule));
@@ -122,7 +123,7 @@ public static class RuleEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -131,7 +132,7 @@ public static class RuleEndpoints
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
if (existing is not null)
{
return Results.Conflict(Error("rule_exists", $"Rule '{request.RuleId}' already exists.", context));
return Results.Conflict(Error("rule_exists", _t("notifier.error.rule_exists", request.RuleId), context));
}
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
@@ -154,7 +155,7 @@ public static class RuleEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -162,7 +163,7 @@ public static class RuleEndpoints
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
var updated = MergeUpdate(existing, request, actor, timeProvider);
@@ -184,7 +185,7 @@ public static class RuleEndpoints
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
return Results.BadRequest(Error("tenant_missing", _t("notifier.error.tenant_missing"), context));
}
var actor = GetActor(context);
@@ -192,7 +193,7 @@ public static class RuleEndpoints
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
return Results.NotFound(Error("rule_not_found", _t("notifier.error.rule_not_found", ruleId), context));
}
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Notifier.Worker.Security;
using static StellaOps.Localization.T;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -20,7 +21,7 @@ public static class SecurityEndpoints
// Signing endpoints
group.MapPost("/tokens/sign", SignTokenAsync)
.WithName("SignToken")
.WithDescription("Signs a payload and returns a HMAC-signed acknowledgment token. The token encodes purpose, subject, tenant, and expiry claims.");
.WithDescription(_t("notifier.security.sign_description"));
group.MapPost("/tokens/verify", VerifyTokenAsync)
.WithName("VerifyToken")

View File

@@ -39,6 +39,7 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Auth.Abstractions;
using StellaOps.Notifier.WebService.Constants;
using StellaOps.Localization;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -126,6 +127,9 @@ builder.Services.AddNotifierSecurityServices(builder.Configuration);
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
// Authentication (resource server JWT validation via Authority)
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
// Authorization policies for Notifier scopes (RASD-03)
builder.Services.AddAuthorization(options =>
{
@@ -138,6 +142,8 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddHealthChecks();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
@@ -152,6 +158,7 @@ var app = builder.Build();
app.LogStellaOpsLocalHostname("notifier");
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
// Enable WebSocket support for live incident feed
app.UseWebSockets(new WebSocketOptions
@@ -175,6 +182,9 @@ app.Use(async (context, next) =>
await next().ConfigureAwait(false);
});
app.UseAuthentication();
app.UseAuthorization();
// Tenant context middleware (extracts and validates tenant from headers/query)
app.UseTenantContext();
app.UseStellaOpsTenantMiddleware();
@@ -3251,6 +3261,7 @@ static object Error(string code, string message, HttpContext context) => new
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.LoadTranslationsAsync();
app.Run();
// Make Program class accessible to test projects using WebApplicationFactory

View File

@@ -16,6 +16,10 @@
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>

View File

@@ -0,0 +1,194 @@
{
"_meta": { "locale": "en-US", "namespace": "notifier", "version": "1.0" },
"notifier.rule.list_description": "Returns all alert routing rules for the tenant. Rules define which events trigger notifications, which channels receive them, and any throttle or digest settings applied.",
"notifier.rule.get_description": "Retrieves a single alert routing rule by its identifier. Returns match criteria, actions, throttle settings, and audit metadata.",
"notifier.rule.create_description": "Creates a new alert routing rule. The rule specifies event match criteria (kinds, namespaces, severities) and the notification actions to execute. An audit entry is written on creation.",
"notifier.rule.update_description": "Updates an existing alert routing rule. Only the provided fields are changed; match criteria, actions, throttle settings, and labels are merged. An audit entry is written on update.",
"notifier.rule.delete_description": "Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.",
"notifier.rule.list2_description": "Returns all alert routing rules for the tenant with optional filtering by enabled state, name prefix, and limit. Rules define event match criteria and the notification actions to execute.",
"notifier.rule.get2_description": "Returns a single alert routing rule by its identifier, including match criteria, actions, throttle settings, labels, and audit metadata.",
"notifier.rule.create2_description": "Creates a new alert routing rule. Returns conflict if a rule with the same ID already exists. An audit entry is written on creation.",
"notifier.rule.update2_description": "Updates an existing alert routing rule. Provided fields are merged with the existing rule. An audit entry is written on update.",
"notifier.rule.delete2_description": "Permanently removes an alert routing rule. Future events will no longer be matched against this rule. An audit entry is written on deletion.",
"notifier.template.list_description": "Lists all notification templates for the tenant, with optional filtering by key prefix, channel type, and locale. Templates define the rendered message body used by notification rules.",
"notifier.template.get_description": "Retrieves a single notification template by its identifier. Returns the template body, channel type, locale, render mode, and audit metadata.",
"notifier.template.upsert_description": "Creates or updates a notification template. The template body supports Scriban syntax with access to event payload fields. Validation is performed before persisting; an error is returned for invalid syntax.",
"notifier.template.delete_description": "Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.",
"notifier.template.preview_description": "Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.",
"notifier.template.validate_description": "Validates a template body for syntax correctness without persisting it. Returns isValid, a list of errors, and any non-fatal warnings.",
"notifier.template.list2_description": "Returns all notification templates for the tenant with optional filtering by key prefix, channel type, and locale. Templates define rendered message bodies used by alert routing rules.",
"notifier.template.get2_description": "Returns a single notification template by its identifier, including body, channel type, locale, render mode, format, and audit metadata.",
"notifier.template.create2_description": "Creates a new notification template. Template body syntax is validated before persisting. Returns conflict if a template with the same ID already exists.",
"notifier.template.update2_description": "Updates an existing notification template. Template body syntax is validated before persisting. An audit entry is written on update.",
"notifier.template.delete2_description": "Permanently removes a notification template. Rules referencing this template will fall back to channel defaults on the next delivery. An audit entry is written on deletion.",
"notifier.template.preview2_description": "Renders a template against a sample event payload without sending any notification. Accepts either an existing templateId or an inline templateBody. Returns the rendered body, subject, and any template warnings.",
"notifier.incident.list_description": "Returns a paginated list of notification incidents for the tenant, grouped by event ID. Supports filtering by status, event kind prefix, time range, and cursor-based pagination.",
"notifier.incident.ack_description": "Acknowledges an incident, recording the actor and an optional comment in the audit log. Does not stop an active escalation; use the escalation stop endpoint for that.",
"notifier.incident.resolve_description": "Marks an incident as resolved, recording the actor, resolution reason, and optional comment in the audit log. Subsequent notifications for this event kind will continue to be processed normally.",
"notifier.incident.list2_description": "Returns a paginated list of notification deliveries for the tenant. Supports filtering by status, event kind, rule ID, time range, and cursor-based pagination.",
"notifier.incident.get2_description": "Returns a single delivery record by its identifier, including status, attempt history, and metadata.",
"notifier.incident.ack2_description": "Acknowledges or resolves a delivery incident, updating its status and appending an audit entry. Accepts an optional resolution type (resolved, dismissed) and comment.",
"notifier.incident.stats_description": "Returns aggregate delivery counts for the tenant, broken down by status, event kind, and rule ID.",
"notifier.escalation_policy.list_description": "Returns all escalation policies for the tenant. Policies define the escalation levels, targets, and timing used when an incident is unacknowledged.",
"notifier.escalation_policy.get_description": "Returns a single escalation policy by identifier, including all levels and target configurations.",
"notifier.escalation_policy.create_description": "Creates a new escalation policy with one or more escalation levels. Each level specifies targets, escalation timeout, and notification mode.",
"notifier.escalation_policy.update_description": "Updates an existing escalation policy. Changes apply to future escalations; in-flight escalations continue with the previous policy configuration.",
"notifier.escalation_policy.delete_description": "Deletes an escalation policy. The policy cannot be deleted if it is referenced by active escalations.",
"notifier.oncall_schedule.list_description": "Returns all on-call rotation schedules for the tenant, including layers, rotation intervals, and enabled state.",
"notifier.oncall_schedule.get_description": "Returns a single on-call schedule by identifier, including all rotation layers and user assignments.",
"notifier.oncall_schedule.create_description": "Creates a new on-call rotation schedule with one or more rotation layers defining users, rotation type, and handoff times.",
"notifier.oncall_schedule.update_description": "Updates an existing on-call schedule. Current on-call assignments recalculate immediately based on the new configuration.",
"notifier.oncall_schedule.delete_description": "Deletes an on-call schedule. Escalation policies referencing this schedule will fall back to direct targets.",
"notifier.oncall_schedule.current_description": "Returns the users currently on-call for the schedule. Accepts an optional atTime query parameter to evaluate a past or future on-call window.",
"notifier.oncall_schedule.create_override_description": "Creates a time-bounded override placing a specific user on-call for a schedule, superseding the normal rotation for that window.",
"notifier.oncall_schedule.delete_override_description": "Removes an on-call override, restoring the standard rotation for the schedule.",
"notifier.escalation.list_description": "Returns all currently active escalations for the tenant, including current level, targets notified, and elapsed time.",
"notifier.escalation.get_description": "Returns the current escalation state for a specific incident, including which level is active and when the next escalation is scheduled.",
"notifier.escalation.start_description": "Starts a new escalation for an incident using the specified policy. Returns conflict if an escalation is already active for the incident.",
"notifier.escalation.manual_description": "Immediately advances the escalation to the next level without waiting for the automatic timeout. An optional reason is recorded in the escalation audit trail.",
"notifier.escalation.stop_description": "Stops an active escalation for an incident. The stop reason is recorded in the audit trail. On-call targets are not notified after stopping.",
"notifier.ack.process_description": "Processes an acknowledgment for an incident from the API. Stops the escalation if one is active and records the acknowledgment in the audit log.",
"notifier.ack.link_description": "Processes an acknowledgment via a signed one-time link token (e.g., from an email notification). The token is validated for expiry and replay before acknowledgment is recorded.",
"notifier.ack.pagerduty_description": "Receives and processes inbound acknowledgment webhooks from PagerDuty. No authentication is required; the request is validated using the PagerDuty webhook signature.",
"notifier.ack.opsgenie_description": "Receives and processes inbound acknowledgment webhooks from OpsGenie. No authentication is required; the request is validated using the OpsGenie webhook signature.",
"notifier.fallback.stats_description": "Returns aggregate delivery statistics for the tenant including primary success rate, fallback attempt count, fallback success rate, and per-channel failure breakdown over the specified window.",
"notifier.fallback.get_chain_description": "Returns the ordered list of fallback channel types that will be tried when the primary channel fails. If no custom chain is configured, the system default is returned.",
"notifier.fallback.set_chain_description": "Creates or replaces the fallback chain for a primary channel type. The chain must reference valid channel types; invalid entries are silently filtered out.",
"notifier.fallback.test_description": "Simulates a channel failure for the specified channel type and returns which fallback channel would be selected next. The simulated delivery state is cleaned up after the test.",
"notifier.fallback.clear_delivery_description": "Removes all in-memory fallback tracking state for a delivery ID. Use this to reset a stuck delivery that has exhausted its fallback chain without entering a terminal status.",
"notifier.override.list_description": "Returns all currently active operator overrides for the tenant, including type (quiet-hours, throttle, maintenance), expiry, and usage counts.",
"notifier.override.get_description": "Returns a single operator override by its identifier, including status, remaining duration, and event kind filters.",
"notifier.override.create_description": "Creates a time-bounded operator override that bypasses quiet hours, throttling, or maintenance windows for the specified event kinds. Requires a reason and duration in minutes.",
"notifier.override.revoke_description": "Immediately revokes an active operator override before its natural expiry. The revocation reason and actor are recorded in the override history.",
"notifier.override.check_description": "Checks whether any active override applies to a given event kind and optional correlation key. Returns the matched override details and the bypass types it grants.",
"notifier.quiet_hours.list_description": "Returns all quiet hours calendars for the tenant, including schedules, enabled state, priority, and event kind filters.",
"notifier.quiet_hours.get_description": "Returns a single quiet hours calendar by its identifier, including all schedule entries and timezone settings.",
"notifier.quiet_hours.create_description": "Creates a new quiet hours calendar defining time windows during which notifications are suppressed. At least one schedule entry is required.",
"notifier.quiet_hours.update_description": "Updates an existing quiet hours calendar. Changes take effect immediately for all subsequent notification evaluations.",
"notifier.quiet_hours.delete_description": "Permanently removes a quiet hours calendar. Notifications that would have been suppressed by this calendar will resume delivering normally.",
"notifier.quiet_hours.evaluate_description": "Checks whether quiet hours are currently active for the specified event kind. Returns the matched calendar, schedule name, and time when quiet hours end if active.",
"notifier.throttle.get_description": "Returns the throttle configuration for the tenant, including the default suppression window, per-event-kind overrides, and burst window settings. Returns platform defaults if no custom configuration exists.",
"notifier.throttle.update_description": "Creates or replaces the throttle configuration for the tenant. The default duration and optional per-event-kind overrides control how long duplicate notifications are suppressed.",
"notifier.throttle.delete_description": "Removes the tenant-specific throttle configuration, reverting all throttle windows to the platform defaults.",
"notifier.throttle.evaluate_description": "Returns the effective throttle duration in seconds for a given event kind, applying the tenant-specific override if present or the default if not.",
"notifier.storm_breaker.list_description": "Returns all currently active notification storms for the tenant. A storm is declared when the same event kind fires at a rate exceeding the configured threshold, triggering suppression.",
"notifier.storm_breaker.get_description": "Returns the current state of a storm identified by its storm key, including event count, suppressed count, and the time of the last summarization.",
"notifier.storm_breaker.summary_description": "Generates and returns a suppression summary notification for the storm, delivering a single digest notification in place of all suppressed individual events.",
"notifier.storm_breaker.clear_description": "Manually clears the storm state for the specified key. Subsequent events of the same kind will be processed normally until a new storm threshold is exceeded.",
"notifier.security.sign_description": "Signs a payload and returns a HMAC-signed acknowledgment token. The token encodes purpose, subject, tenant, and expiry claims.",
"notifier.security.verify_description": "Verifies a signed token and returns the decoded payload if valid. Returns an error if the token is expired, tampered, or issued by a rotated key.",
"notifier.security.token_info_description": "Decodes and returns structural information about a token without performing cryptographic verification. Useful for debugging expired or unknown tokens.",
"notifier.security.rotate_key_description": "Rotates the active signing key. Previously signed tokens remain verifiable during the overlap window. Old keys are retired after the configured grace period.",
"notifier.security.register_webhook_description": "Registers or replaces the webhook security configuration for a channel, including the shared secret and allowed IP ranges.",
"notifier.security.get_webhook_description": "Returns the webhook security configuration for a tenant and channel. The secret is not included in the response.",
"notifier.security.validate_webhook_description": "Validates an inbound webhook request against its registered security configuration, verifying the signature and checking the source IP against the allowlist.",
"notifier.security.update_webhook_allowlist_description": "Replaces the IP allowlist for a webhook channel. An empty list removes all IP restrictions.",
"notifier.security.sanitize_html_description": "Sanitizes HTML content using the specified profile (or the default profile if omitted), removing disallowed tags and attributes.",
"notifier.security.validate_html_description": "Validates HTML content against the specified profile and returns whether it is safe, along with details of any disallowed elements found.",
"notifier.security.strip_html_description": "Removes all HTML tags from the input, returning plain text. Useful for generating fallback plain-text notification bodies from HTML templates.",
"notifier.security.validate_tenant_description": "Validates whether the calling tenant is permitted to access the specified resource type and ID for the requested operation. Returns a violation record if access is denied.",
"notifier.security.get_violations_description": "Returns recorded tenant isolation violations for the specified tenant, optionally filtered by time range.",
"notifier.security.fuzz_test_description": "Runs automated tenant isolation fuzz tests, exercising cross-tenant access paths to surface potential data-leakage vulnerabilities.",
"notifier.security.grant_cross_tenant_description": "Grants a target tenant time-bounded access to a resource owned by the owner tenant. Grant records are auditable and expire automatically.",
"notifier.security.revoke_cross_tenant_description": "Revokes a previously granted cross-tenant access grant before its expiry. Revocation is immediate and recorded in the audit log.",
"notifier.simulation.simulate_description": "Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.",
"notifier.simulation.validate_description": "Validates a rule definition and returns any errors or warnings.",
"notifier.localization.list_bundles_description": "Returns all localization bundles for the tenant, including bundle ID, locale, namespace, string count, priority, and enabled state.",
"notifier.localization.get_locales_description": "Returns the distinct set of locale codes for which at least one enabled localization bundle exists for the tenant.",
"notifier.localization.get_bundle_description": "Returns the merged set of all localized strings for the specified locale, combining bundles in priority order.",
"notifier.localization.get_string_description": "Resolves a single localized string by key and locale, falling back to en-US if the key is absent in the requested locale.",
"notifier.localization.format_string_description": "Resolves a localized string and applies named parameter substitution using the provided parameters dictionary. Returns the formatted string and the effective locale used.",
"notifier.localization.upsert_bundle_description": "Creates a new localization bundle or replaces an existing one for the given locale and namespace. Returns 201 on creation or 200 on update.",
"notifier.localization.delete_bundle_description": "Permanently removes a localization bundle by bundle ID. Strings in the deleted bundle will no longer be resolved; other bundles for the same locale continue to function.",
"notifier.localization.validate_bundle_description": "Validates a localization bundle for structural correctness, required fields, and locale code format without persisting it. Returns isValid, errors, and warnings.",
"notifier.observability.metrics_description": "Returns a snapshot of current Notifier service metrics across all tenants, including dispatch rates, error counts, and channel health.",
"notifier.observability.tenant_metrics_description": "Returns a metrics snapshot scoped to a specific tenant, including per-channel delivery rates and recent error totals.",
"notifier.observability.dead_letters_description": "Returns paginated dead letter queue entries for the tenant. Dead letters are deliveries that exhausted all retry and fallback attempts.",
"notifier.observability.dead_letter_get_description": "Returns a single dead letter entry by its identifier, including the original payload, error reason, and all previous attempt details.",
"notifier.observability.dead_letter_retry_description": "Re-enqueues a dead letter delivery for reprocessing. The entry is removed from the dead letter queue on success.",
"notifier.observability.dead_letter_discard_description": "Permanently discards a dead letter entry with an optional reason. The entry is removed from the dead letter queue and an audit record is written.",
"notifier.observability.dead_letter_stats_description": "Returns aggregate dead letter statistics for the tenant, including total count, by-channel breakdown, and average age of entries.",
"notifier.observability.dead_letter_purge_description": "Removes dead letter entries older than the specified number of days. Returns the count of purged entries.",
"notifier.observability.chaos_list_description": "Returns all chaos experiments, optionally filtered by status. Chaos experiments inject controlled failures to verify Notifier resilience.",
"notifier.observability.chaos_get_description": "Returns the configuration and current state of a single chaos experiment by its identifier.",
"notifier.observability.chaos_start_description": "Starts a chaos experiment that injects faults into the notification pipeline. Only one experiment per fault type may run concurrently.",
"notifier.observability.chaos_stop_description": "Stops a running chaos experiment and removes its fault injection. Normal notification delivery resumes immediately.",
"notifier.observability.chaos_results_description": "Returns the collected results of a chaos experiment, including injected failure counts, observed retry behavior, and outcome summary.",
"notifier.observability.retention_list_description": "Returns the active retention policies for the Notifier service, including delivery record TTLs and dead letter purge windows.",
"notifier.observability.retention_get_description": "Returns a single retention policy by its identifier.",
"notifier.observability.retention_create_description": "Creates a new retention policy. Returns conflict if a policy with the same ID already exists.",
"notifier.observability.retention_update_description": "Updates an existing retention policy. Changes take effect on the next scheduled or manually triggered retention execution.",
"notifier.observability.retention_delete_description": "Deletes a retention policy, reverting the associated data type to the system default retention window.",
"notifier.observability.retention_execute_description": "Immediately triggers retention cleanup for the specified policy or all policies. Returns the count of records deleted.",
"notifier.observability.retention_preview_description": "Returns the count and identifiers of records that would be deleted if the retention policy were executed now, without deleting anything.",
"notifier.observability.retention_history_description": "Returns the most recent retention execution records for the policy, including run time, records deleted, and any errors encountered.",
"notifier.error.tenant_missing": "X-StellaOps-Tenant header is required.",
"notifier.error.tenant_id_missing": "X-Tenant-Id header is required.",
"notifier.error.tenant_required": "Tenant ID is required via X-Tenant-Id header or request body.",
"notifier.error.tenant_required_stellaops": "Tenant ID is required.",
"notifier.error.rule_not_found": "Rule {0} not found.",
"notifier.error.rule_exists": "Rule '{0}' already exists.",
"notifier.error.template_not_found": "Template {0} not found.",
"notifier.error.template_exists": "Template '{0}' already exists.",
"notifier.error.template_validation_failed": "Validation failed.",
"notifier.error.template_required": "Either templateId or templateBody must be provided.",
"notifier.error.template_body_required": "templateBody is required.",
"notifier.error.invalid_channel_type": "Invalid channel type: {0}",
"notifier.error.incident_not_found": "Incident '{0}' not found.",
"notifier.error.policy_not_found": "Policy '{0}' not found.",
"notifier.error.policy_id_required": "Policy ID is required.",
"notifier.error.policy_name_required": "Policy name is required.",
"notifier.error.policy_levels_required": "At least one escalation level is required.",
"notifier.error.schedule_not_found": "Schedule '{0}' not found.",
"notifier.error.schedule_name_required": "Schedule name is required.",
"notifier.error.escalation_not_found": "No escalation found for incident '{0}'.",
"notifier.error.active_escalation_not_found": "No active escalation found for incident '{0}'.",
"notifier.error.override_not_found": "Override '{0}' not found.",
"notifier.error.override_not_found_or_inactive": "Override '{0}' not found or already inactive.",
"notifier.error.actor_required": "Actor is required via X-Actor header or request body.",
"notifier.error.reason_required": "Reason is required.",
"notifier.error.duration_required": "Duration must be a positive value in minutes.",
"notifier.error.event_kind_required": "Event kind is required.",
"notifier.error.calendar_not_found": "Calendar '{0}' not found.",
"notifier.error.calendar_name_required": "Calendar name is required.",
"notifier.error.calendar_schedules_required": "At least one schedule is required.",
"notifier.error.throttle_default_duration_required": "Default duration must be a positive value in seconds.",
"notifier.error.no_throttle_config": "No throttle configuration exists for this tenant.",
"notifier.error.storm_not_found": "No storm found with key '{0}'",
"notifier.error.invalid_fallback_channel": "Invalid channel type: {0}",
"notifier.error.pagerduty_not_configured": "PagerDuty integration not configured.",
"notifier.error.opsgenie_not_configured": "OpsGenie integration not configured.",
"notifier.error.dead_letter_not_found": "Dead letter entry not found",
"notifier.error.experiment_not_found": "Experiment not found",
"notifier.error.retention_policy_not_found": "Policy not found",
"notifier.error.bundle_not_found": "Bundle '{0}' not found",
"notifier.error.websocket_required": "This endpoint requires a WebSocket connection.",
"notifier.error.tenant_missing_websocket": "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
"notifier.message.fallback_chain_updated": "Fallback chain updated successfully",
"notifier.message.delivery_state_cleared": "Delivery state for '{0}' cleared",
"notifier.message.key_rotated": "Key rotated successfully",
"notifier.message.storm_cleared": "Storm '{0}' cleared successfully",
"notifier.message.bundle_created": "Bundle created successfully",
"notifier.message.bundle_updated": "Bundle updated successfully",
"notifier.message.bundle_deleted": "Bundle '{0}' deleted successfully",
"notifier.message.websocket_unknown_type": "Unknown message type: {0}",
"notifier.message.websocket_invalid_json": "Invalid JSON message"
}