Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.WebService — Agent Charter
## Mission
Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`.

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Engine;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload describing channel health diagnostics.
/// </summary>
public sealed record ChannelHealthResponse(
string TenantId,
string ChannelId,
ChannelHealthStatus Status,
string? Message,
DateTimeOffset CheckedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Payload for Notify channel test send requests.
/// </summary>
public sealed record ChannelTestSendRequest
{
/// <summary>
/// Optional override for the default channel destination (email address, webhook URL, Slack channel, etc.).
/// </summary>
public string? Target { get; init; }
/// <summary>
/// Optional template identifier to drive future rendering hooks.
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Preview title (fallback supplied when omitted).
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Optional short summary to show in UI cards.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Primary body payload rendered for the connector.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Optional text-only representation (used by email/plaintext destinations).
/// </summary>
public string? TextBody { get; init; }
/// <summary>
/// Optional locale hint (RFC 5646).
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Optional metadata for future expansion (headers, context labels, etc.).
/// </summary>
public IDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Optional attachment references emitted with the preview.
/// </summary>
[JsonPropertyName("attachments")]
public IList<string>? AttachmentRefs { get; init; }
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload summarising a Notify channel test send preview.
/// </summary>
public sealed record ChannelTestSendResponse(
string TenantId,
string ChannelId,
NotifyDeliveryRendered Preview,
DateTimeOffset QueuedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);

View File

@@ -0,0 +1,5 @@
namespace StellaOps.Notify.WebService.Contracts;
internal sealed record AcquireLockRequest(string Resource, string Owner, int TtlSeconds);
internal sealed record ReleaseLockRequest(string Resource, string Owner);

View File

@@ -0,0 +1,47 @@
using System;
namespace StellaOps.Notify.WebService.Diagnostics;
/// <summary>
/// Tracks Notify WebService readiness information for `/readyz`.
/// </summary>
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private ReadySnapshot _readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_startedAt = _timeProvider.GetUtcNow();
_readySnapshot = ReadySnapshot.CreateInitial(_startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = _timeProvider.GetUtcNow();
return new ServiceSnapshot(_startedAt, now, _readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? errorMessage = null)
{
var timestamp = _timeProvider.GetUtcNow();
_readySnapshot = new ReadySnapshot(timestamp, latency, success, success ? null : errorMessage);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new(timestamp, null, false, "initialising");
}
}

View File

@@ -0,0 +1,37 @@
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Notify.WebService.Extensions;
internal static class ConfigurationExtensions
{
public static IConfigurationBuilder AddNotifyYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.IO;
using StellaOps.Notify.WebService.Options;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Hosting;
internal static class NotifyPluginHostFactory
{
public static PluginHostOptions Build(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var hostOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? Path.Combine(contentRootPath, ".."),
PluginsDirectory = options.Plugins.Directory ?? Path.Combine("plugins", "notify"),
PrimaryPrefix = "StellaOps.Notify"
};
if (!Path.IsPathRooted(hostOptions.BaseDirectory))
{
hostOptions.BaseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, hostOptions.BaseDirectory));
}
if (!Path.IsPathRooted(hostOptions.PluginsDirectory))
{
hostOptions.PluginsDirectory = Path.Combine(hostOptions.BaseDirectory, hostOptions.PluginsDirectory);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var prefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(prefix);
}
return hostOptions;
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Notify.WebService.Internal;
internal sealed class JsonHttpResult : IResult
{
private readonly string _payload;
private readonly int _statusCode;
private readonly string? _location;
public JsonHttpResult(string payload, int statusCode, string? location)
{
_payload = payload;
_statusCode = statusCode;
_location = location;
}
public async Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = _statusCode;
httpContext.Response.ContentType = "application/json";
if (!string.IsNullOrWhiteSpace(_location))
{
httpContext.Response.Headers.Location = _location;
}
await httpContext.Response.WriteAsync(_payload);
}
}

View File

@@ -0,0 +1,148 @@
using System.Collections.Generic;
namespace StellaOps.Notify.WebService.Options;
/// <summary>
/// Strongly typed configuration for the Notify WebService host.
/// </summary>
public sealed class NotifyWebServiceOptions
{
public const string SectionName = "notify";
/// <summary>
/// Schema version that downstream consumers can use to detect breaking changes.
/// </summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// Authority / authentication configuration.
/// </summary>
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Mongo storage configuration for configuration state and audit logs.
/// </summary>
public StorageOptions Storage { get; set; } = new();
/// <summary>
/// Plug-in loader configuration.
/// </summary>
public PluginOptions Plugins { get; set; } = new();
/// <summary>
/// HTTP API behaviour.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Telemetry configuration toggles.
/// </summary>
public TelemetryOptions Telemetry { get; set; } = new();
public sealed class AuthorityOptions
{
public bool Enabled { get; set; } = true;
public bool AllowAnonymousFallback { get; set; }
public string Issuer { get; set; } = "https://authority.local";
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public IList<string> Audiences { get; set; } = new List<string> { "notify" };
public string ReadScope { get; set; } = "notify.read";
public string AdminScope { get; set; } = "notify.admin";
/// <summary>
/// Optional development signing key for symmetric JWT validation when Authority is disabled.
/// </summary>
public string? DevelopmentSigningKey { get; set; }
}
public sealed class StorageOptions
{
public string Driver { get; set; } = "mongo";
public string ConnectionString { get; set; } = string.Empty;
public string Database { get; set; } = "notify";
public int CommandTimeoutSeconds { get; set; } = 30;
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }
public string? Directory { get; set; }
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> OrderedPlugins { get; set; } = new List<string>();
}
public sealed class ApiOptions
{
public string BasePath { get; set; } = "/api/v1/notify";
public string InternalBasePath { get; set; } = "/internal/notify";
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
public RateLimitOptions RateLimits { get; set; } = new();
}
public sealed class RateLimitOptions
{
public RateLimitPolicyOptions DeliveryHistory { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 60,
tokensPerPeriod: 30,
replenishmentPeriodSeconds: 60,
queueLimit: 20);
public RateLimitPolicyOptions TestSend { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 5,
tokensPerPeriod: 5,
replenishmentPeriodSeconds: 60,
queueLimit: 2);
}
public sealed class RateLimitPolicyOptions
{
public bool Enabled { get; set; } = true;
public int TokenLimit { get; set; } = 10;
public int TokensPerPeriod { get; set; } = 10;
public int ReplenishmentPeriodSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 0;
public static RateLimitPolicyOptions CreateDefault(int tokenLimit, int tokensPerPeriod, int replenishmentPeriodSeconds, int queueLimit)
{
return new RateLimitPolicyOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriodSeconds = replenishmentPeriodSeconds,
QueueLimit = queueLimit
};
}
}
public sealed class TelemetryOptions
{
public bool EnableRequestLogging { get; set; } = true;
public string MinimumLogLevel { get; set; } = "Information";
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsPostConfigure
{
public static void Apply(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
NormalizePluginOptions(options.Plugins, contentRootPath);
}
private static void NormalizePluginOptions(NotifyWebServiceOptions.PluginOptions plugins, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(plugins);
var baseDirectory = plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
plugins.BaseDirectory = baseDirectory;
if (string.IsNullOrWhiteSpace(plugins.Directory))
{
plugins.Directory = Path.Combine("plugins", "notify");
}
if (!Path.IsPathRooted(plugins.Directory))
{
plugins.Directory = Path.Combine(baseDirectory, plugins.Directory);
}
if (plugins.SearchPatterns.Count == 0)
{
plugins.SearchPatterns.Add("StellaOps.Notify.Connectors.*.dll");
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Linq;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsValidator
{
public static void Validate(NotifyWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ValidateStorage(options.Storage);
ValidateAuthority(options.Authority);
ValidateApi(options.Api);
}
private static void ValidateStorage(NotifyWebServiceOptions.StorageOptions storage)
{
ArgumentNullException.ThrowIfNull(storage);
var driver = storage.Driver ?? string.Empty;
if (!string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(driver, "memory", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'.");
}
if (string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
{
throw new InvalidOperationException("notify:storage:connectionString must be provided.");
}
if (string.IsNullOrWhiteSpace(storage.Database))
{
throw new InvalidOperationException("notify:storage:database must be provided.");
}
if (storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("notify:storage:commandTimeoutSeconds must be positive.");
}
}
}
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)
{
ArgumentNullException.ThrowIfNull(authority);
if (authority.Enabled)
{
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("notify:authority:issuer must be provided when authority is enabled.");
}
if (authority.Audiences is null || authority.Audiences.Count == 0)
{
throw new InvalidOperationException("notify:authority:audiences must include at least one value.");
}
if (string.IsNullOrWhiteSpace(authority.AdminScope) || string.IsNullOrWhiteSpace(authority.ReadScope))
{
throw new InvalidOperationException("notify:authority admin and read scopes must be configured.");
}
}
else
{
if (string.IsNullOrWhiteSpace(authority.DevelopmentSigningKey) || authority.DevelopmentSigningKey.Length < 32)
{
throw new InvalidOperationException("notify:authority:developmentSigningKey must be at least 32 characters when authority is disabled.");
}
}
}
private static void ValidateApi(NotifyWebServiceOptions.ApiOptions api)
{
ArgumentNullException.ThrowIfNull(api);
if (!api.BasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:basePath must start with '/'.");
}
if (!api.InternalBasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:internalBasePath must start with '/'.");
}
if (string.IsNullOrWhiteSpace(api.TenantHeader))
{
throw new InvalidOperationException("notify:api:tenantHeader must be provided.");
}
ValidateRateLimits(api.RateLimits);
}
private static void ValidateRateLimits(NotifyWebServiceOptions.RateLimitOptions rateLimits)
{
ArgumentNullException.ThrowIfNull(rateLimits);
ValidatePolicy(rateLimits.DeliveryHistory, "notify:api:rateLimits:deliveryHistory");
ValidatePolicy(rateLimits.TestSend, "notify:api:rateLimits:testSend");
static void ValidatePolicy(NotifyWebServiceOptions.RateLimitPolicyOptions options, string prefix)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled)
{
return;
}
if (options.TokenLimit <= 0)
{
throw new InvalidOperationException($"{prefix}:tokenLimit must be positive when enabled.");
}
if (options.TokensPerPeriod <= 0)
{
throw new InvalidOperationException($"{prefix}:tokensPerPeriod must be positive when enabled.");
}
if (options.ReplenishmentPeriodSeconds <= 0)
{
throw new InvalidOperationException($"{prefix}:replenishmentPeriodSeconds must be positive when enabled.");
}
if (options.QueueLimit < 0)
{
throw new InvalidOperationException($"{prefix}:queueLimit cannot be negative.");
}
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Plugins;
internal interface INotifyPluginRegistry
{
Task<int> WarmupAsync(CancellationToken cancellationToken = default);
}
internal sealed class NotifyPluginRegistry : INotifyPluginRegistry
{
private readonly PluginHostOptions _hostOptions;
private readonly ILogger<NotifyPluginRegistry> _logger;
public NotifyPluginRegistry(
PluginHostOptions hostOptions,
ILogger<NotifyPluginRegistry> logger)
{
_hostOptions = hostOptions ?? throw new ArgumentNullException(nameof(hostOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<int> WarmupAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = PluginHost.LoadPlugins(_hostOptions, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning(
"No Notify plug-ins discovered under '{PluginDirectory}'.",
result.PluginDirectory);
}
else
{
_logger.LogInformation(
"Loaded {PluginCount} Notify plug-in(s) from '{PluginDirectory}'.",
result.Plugins.Count,
result.PluginDirectory);
}
if (result.MissingOrderedPlugins.Count > 0)
{
_logger.LogWarning(
"Configured plug-ins missing from disk: {Missing}.",
string.Join(", ", result.MissingOrderedPlugins));
}
return Task.FromResult(result.Plugins.Count);
}
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Notify.WebService;
public partial class Program;

View File

@@ -0,0 +1,850 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Serilog;
using Serilog.Events;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notify.WebService.Diagnostics;
using StellaOps.Notify.WebService.Extensions;
using StellaOps.Notify.WebService.Hosting;
using StellaOps.Notify.WebService.Options;
using StellaOps.Notify.WebService.Plugins;
using StellaOps.Notify.WebService.Security;
using StellaOps.Notify.WebService.Services;
using StellaOps.Notify.WebService.Internal;
using StellaOps.Notify.WebService.Storage.InMemory;
using StellaOps.Plugin.DependencyInjection;
using MongoDB.Bson;
using StellaOps.Notify.WebService.Contracts;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "NOTIFY_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml"));
};
});
var contentRootPath = builder.Environment.ContentRootPath;
var bootstrapOptions = builder.Configuration.BindOptions<NotifyWebServiceOptions>(
NotifyWebServiceOptions.SectionName,
(opts, _) =>
{
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
NotifyWebServiceOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<NotifyWebServiceOptions>()
.Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName))
.PostConfigure(options =>
{
NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath);
NotifyWebServiceOptionsValidator.Validate(options);
})
.ValidateOnStart();
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
loggerConfiguration
.MinimumLevel.Is(minimumLevel)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ServiceStatus>();
builder.Services.AddSingleton<NotifySchemaMigrationService>();
if (string.Equals(bootstrapOptions.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddNotifyMongoStorage(builder.Configuration.GetSection("notify:storage"));
}
else
{
builder.Services.AddInMemoryNotifyStorage();
}
var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath);
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureRateLimiting(builder, bootstrapOptions);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
ConfigureRequestPipeline(app, bootstrapOptions);
ConfigureEndpoints(app);
await app.RunAsync();
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options)
{
if (options.Authority.Enabled)
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = options.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = options.Authority.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds);
resourceOptions.Audiences.Clear();
foreach (var audience in options.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
});
builder.Services.AddAuthorization(auth =>
{
auth.AddStellaOpsScopePolicy(NotifyPolicies.Read, options.Authority.ReadScope);
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
});
}
else
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwt =>
{
jwt.RequireHttpsMetadata = false;
jwt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = options.Authority.Issuer,
ValidateAudience = options.Authority.Audiences.Count > 0,
ValidAudiences = options.Authority.Audiences,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
NameClaimType = ClaimTypes.Name
};
});
builder.Services.AddAuthorization(auth =>
{
auth.AddPolicy(
NotifyPolicies.Read,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx =>
HasScope(ctx.User, options.Authority.ReadScope) ||
HasScope(ctx.User, options.Authority.AdminScope)));
auth.AddPolicy(
NotifyPolicies.Admin,
policy => policy
.RequireAuthenticatedUser()
.RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope)));
});
}
}
static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var tenantHeader = options.Api.TenantHeader;
var limits = options.Api.RateLimits;
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
rateLimiterOptions.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, limits.DeliveryHistory, tenantHeader, "deliveries");
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, limits.TestSend, tenantHeader, "channel-test");
});
static void ConfigurePolicy(
RateLimiterOptions rateLimiterOptions,
string policyName,
NotifyWebServiceOptions.RateLimitPolicyOptions policy,
string tenantHeader,
string prefix)
{
rateLimiterOptions.AddPolicy(policyName, httpContext =>
{
if (policy is null || !policy.Enabled)
{
return RateLimitPartition.GetNoLimiter("notify-disabled");
}
var identity = ResolveIdentity(httpContext, tenantHeader, prefix);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = policy.TokenLimit,
TokensPerPeriod = policy.TokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds),
QueueLimit = policy.QueueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
});
}
static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix)
{
var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header)
? header.ToString().Trim()
: "anonymous";
var subject = httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.Identity?.Name
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return string.Concat(prefix, ':', tenant, ':', subject);
}
}
static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options)
{
var stopwatch = Stopwatch.StartNew();
try
{
await using var scope = services.CreateAsyncScope();
if (string.Equals(options.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
await RunMongoMigrationsAsync(scope.ServiceProvider);
}
var registry = scope.ServiceProvider.GetRequiredService<INotifyPluginRegistry>();
var count = await registry.WarmupAsync();
stopwatch.Stop();
status.RecordReadyCheck(success: true, stopwatch.Elapsed);
logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message);
logger.LogError(ex, "Failed to initialise Notify WebService.");
throw;
}
}
static async Task RunMongoMigrationsAsync(IServiceProvider services)
{
var initializerType = Type.GetType("StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo");
if (initializerType is null)
{
return;
}
var initializer = services.GetService(initializerType);
if (initializer is null)
{
return;
}
var method = initializerType.GetMethod("EnsureIndexesAsync", new[] { typeof(CancellationToken) });
if (method is null)
{
return;
}
if (method.Invoke(initializer, new object[] { CancellationToken.None }) is Task task)
{
await task.ConfigureAwait(false);
}
}
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options)
{
if (options.Telemetry.EnableRequestLogging)
{
app.UseSerilogRequestLogging(c =>
{
c.IncludeQueryInRequestPath = true;
c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
});
}
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
}
static void ConfigureEndpoints(WebApplication app)
{
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", (ServiceStatus status) =>
{
var snapshot = status.CreateSnapshot();
if (snapshot.Ready.IsReady)
{
return Results.Ok(new
{
status = "ready",
checkedAt = snapshot.Ready.CheckedAt,
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds,
snapshot.StartedAt
});
}
return JsonResponse(
new
{
status = "unready",
snapshot.Ready.Error,
checkedAt = snapshot.Ready.CheckedAt,
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
},
StatusCodes.Status503ServiceUnavailable);
});
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
var tenantHeader = options.Api.TenantHeader;
var apiBasePath = options.Api.BasePath.TrimEnd('/');
var apiGroup = app.MapGroup(options.Api.BasePath);
var internalGroup = app.MapGroup(options.Api.InternalBasePath);
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
.WithName("notify.rules.normalize")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
.WithName("notify.channels.normalize");
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
.WithName("notify.templates.normalize");
apiGroup.MapGet("/rules", async ([FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var rules = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(rules);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, [FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var rule = await repository.GetAsync(tenant, ruleId, cancellationToken);
return rule is null ? Results.NotFound() : JsonResponse(rule);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var rule = service.UpgradeRule(body);
if (!string.Equals(rule.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(rule, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", rule.RuleId), rule);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, ruleId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/channels", async (INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var channels = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(channels);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var channel = service.UpgradeChannel(body);
if (!string.Equals(channel.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(channel, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channel.ChannelId), channel);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/channels/{channelId}/test", async (string channelId, [FromBody] ChannelTestSendRequest? request, INotifyChannelRepository repository, INotifyChannelTestService testService, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (request is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
if (channel is null)
{
return Results.NotFound();
}
try
{
var response = await testService.SendAsync(tenant, channel, request, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
return JsonResponse(response, StatusCodes.Status202Accepted);
}
catch (ChannelTestSendValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.RequireAuthorization(NotifyPolicies.Admin)
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
apiGroup.MapGet("/channels/{channelId}/health", async (string channelId, INotifyChannelRepository repository, INotifyChannelHealthService healthService, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
if (channel is null)
{
return Results.NotFound();
}
var response = await healthService.CheckAsync(tenant, channel, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
return JsonResponse(response);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, channelId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/templates", async (INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var templates = await repository.ListAsync(tenant, cancellationToken);
return JsonResponse(templates);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var template = service.UpgradeTemplate(body);
if (!string.Equals(template.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(template, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", template.TemplateId), template);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.DeleteAsync(tenant, templateId, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/deliveries", async (JsonNode? body, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpdateAsync(delivery, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId), delivery);
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/deliveries", async ([FromServices] INotifyDeliveryRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] string? status, [FromQuery] int? limit, [FromQuery] string? continuationToken, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var effectiveLimit = NormalizeLimit(limit);
var result = await repository.QueryAsync(tenant, since, status, effectiveLimit, continuationToken, cancellationToken).ConfigureAwait(false);
var payload = new
{
items = result.Items,
continuationToken = result.ContinuationToken,
count = result.Items.Count
};
return JsonResponse(payload);
})
.RequireAuthorization(NotifyPolicies.Read)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapGet("/deliveries/{deliveryId}", async (string deliveryId, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var delivery = await repository.GetAsync(tenant, deliveryId, cancellationToken);
return delivery is null ? Results.NotFound() : JsonResponse(delivery);
})
.RequireAuthorization(NotifyPolicies.Read)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapPost("/digests", async ([FromBody] NotifyDigestDocument payload, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (!string.Equals(payload.TenantId, tenant, StringComparison.Ordinal))
{
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
}
await repository.UpsertAsync(payload, cancellationToken);
return Results.Ok();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var digest = await repository.GetAsync(tenant, actionKey, cancellationToken);
return digest is null ? Results.NotFound() : JsonResponse(digest);
})
.RequireAuthorization(NotifyPolicies.Read);
apiGroup.MapDelete("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.RemoveAsync(tenant, actionKey, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken);
return JsonResponse(new { acquired });
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken);
return Results.NoContent();
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
var action = body["action"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(action))
{
return Results.BadRequest(new { error = "Action is required." });
}
var entry = new NotifyAuditEntryDocument
{
Id = ObjectId.GenerateNewId(),
TenantId = tenant,
Action = action,
Actor = user.Identity?.Name ?? "unknown",
EntityId = body["entityId"]?.GetValue<string>() ?? string.Empty,
EntityType = body["entityType"]?.GetValue<string>() ?? string.Empty,
Timestamp = DateTimeOffset.UtcNow,
Payload = body["payload"] is JsonObject payloadObj
? BsonDocument.Parse(payloadObj.ToJsonString())
: new BsonDocument()
};
await repository.AppendAsync(entry, cancellationToken);
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", entry.Id.ToString()), new { entry.Id });
})
.RequireAuthorization(NotifyPolicies.Admin);
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] int? limit, CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var entries = await repository.QueryAsync(tenant, since, limit, cancellationToken);
var response = entries.Select(e => new
{
e.Id,
e.TenantId,
e.Actor,
e.Action,
e.EntityId,
e.EntityType,
e.Timestamp,
Payload = JsonNode.Parse(e.Payload.ToJson())
});
return JsonResponse(response);
})
.RequireAuthorization(NotifyPolicies.Read);
}
static int NormalizeLimit(int? value)
{
if (value is null || value <= 0)
{
return 50;
}
return Math.Min(value.Value, 200);
}
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
{
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
{
tenant = string.Empty;
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
return false;
}
tenant = header.ToString().Trim();
error = null;
return true;
}
static string BuildResourceLocation(string basePath, params string[] segments)
{
if (segments.Length == 0)
{
return basePath;
}
var builder = new StringBuilder(basePath);
foreach (var segment in segments)
{
builder.Append('/');
builder.Append(Uri.EscapeDataString(segment));
}
return builder.ToString();
}
static IResult JsonResponse<T>(T value, int statusCode = StatusCodes.Status200OK, string? location = null)
{
var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new JsonHttpResult(payload, statusCode, location);
}
static IResult CreatedJson<T>(string location, T value)
=> JsonResponse(value, StatusCodes.Status201Created, location);
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
{
if (body is null)
{
return Results.BadRequest(new { error = "Request body is required." });
}
try
{
var model = upgrade(body);
var json = NotifyCanonicalJsonSerializer.Serialize(model);
return Results.Content(json, "application/json");
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
static bool HasScope(ClaimsPrincipal principal, string scope)
{
if (principal is null || string.IsNullOrWhiteSpace(scope))
{
return false;
}
foreach (var claim in principal.FindAll("scope"))
{
if (string.Equals(claim.Value, scope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
static LogEventLevel MapLogLevel(string configuredLevel)
{
return configuredLevel?.ToLowerInvariant() switch
{
"verbose" => LogEventLevel.Verbose,
"debug" => LogEventLevel.Debug,
"warning" => LogEventLevel.Warning,
"error" => LogEventLevel.Error,
"fatal" => LogEventLevel.Fatal,
_ => LogEventLevel.Information
};
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Notify.WebService.Security;
internal static class NotifyPolicies
{
public const string Read = "notify.read";
public const string Admin = "notify.admin";
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Notify.WebService.Security;
internal static class NotifyRateLimitPolicies
{
public const string DeliveryHistory = "notify-deliveries";
public const string TestSend = "notify-test-send";
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelHealthService
{
Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelHealthService : INotifyChannelHealthService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelHealthService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> _providers;
public NotifyChannelHealthService(
TimeProvider timeProvider,
ILogger<NotifyChannelHealthService> logger,
IEnumerable<INotifyChannelHealthProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelHealthProvider>(), _logger);
}
public async Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
cancellationToken.ThrowIfCancellationRequested();
var target = ResolveTarget(channel);
var timestamp = _timeProvider.GetUtcNow();
var context = new ChannelHealthContext(
tenantId,
channel,
target,
timestamp,
traceId);
ChannelHealthResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.CheckAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Notify channel health provider {Provider} failed for tenant {TenantId}, channel {ChannelId} ({ChannelType}).",
provider.GetType().FullName,
tenantId,
channel.ChannelId,
channel.Type);
providerResult = new ChannelHealthResult(
ChannelHealthStatus.Degraded,
"Channel health provider threw an exception. See logs for details.",
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
var metadata = MergeMetadata(context, providerName, providerResult?.Metadata);
var status = providerResult?.Status ?? ChannelHealthStatus.Healthy;
var message = providerResult?.Message ?? "Channel metadata returned without provider-specific diagnostics.";
var response = new ChannelHealthResponse(
tenantId,
channel.ChannelId,
status,
message,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify channel health generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> BuildProviderMap(
IEnumerable<INotifyChannelHealthProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelHealthProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel health providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static string ResolveTarget(NotifyChannel channel)
{
var target = channel.Config.Target ?? channel.Config.Endpoint;
if (string.IsNullOrWhiteSpace(target))
{
return channel.Name;
}
return target;
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelHealthContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId,
["channelEnabled"] = context.Channel.Enabled.ToString()
};
foreach (var label in context.Channel.Labels)
{
metadata[$"label.{label.Key}"] = label.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
}

View File

@@ -0,0 +1,313 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelTestService
{
Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelTestService : INotifyChannelTestService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelTestService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> _providers;
public NotifyChannelTestService(
TimeProvider timeProvider,
ILogger<NotifyChannelTestService> logger,
IEnumerable<INotifyChannelTestProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelTestProvider>(), _logger);
}
public async Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
if (!channel.Enabled)
{
throw new ChannelTestSendValidationException("Channel is disabled. Enable it before issuing test sends.");
}
var target = ResolveTarget(channel, request.Target);
var timestamp = _timeProvider.GetUtcNow();
var previewRequest = BuildPreviewRequest(request);
var context = new ChannelTestPreviewContext(
tenantId,
channel,
target,
previewRequest,
timestamp,
traceId);
ChannelTestPreviewResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.BuildPreviewAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (ChannelTestPreviewException ex)
{
throw new ChannelTestSendValidationException(ex.Message);
}
}
var rendered = providerResult is not null
? EnsureBodyHash(providerResult.Preview)
: CreateFallbackPreview(context);
var metadata = MergeMetadata(
context,
providerName,
providerResult?.Metadata);
var response = new ChannelTestSendResponse(
tenantId,
channel.ChannelId,
rendered,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify test send preview generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> BuildProviderMap(
IEnumerable<INotifyChannelTestProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelTestProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel test providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static ChannelTestPreviewRequest BuildPreviewRequest(ChannelTestSendRequest request)
{
return new ChannelTestPreviewRequest(
TrimToNull(request.Target),
TrimToNull(request.TemplateId),
TrimToNull(request.Title),
TrimToNull(request.Summary),
request.Body,
TrimToNull(request.TextBody),
TrimToLowerInvariant(request.Locale),
NormalizeInputMetadata(request.Metadata),
NormalizeAttachments(request.AttachmentRefs));
}
private static string ResolveTarget(NotifyChannel channel, string? overrideTarget)
{
var target = string.IsNullOrWhiteSpace(overrideTarget)
? channel.Config.Target ?? channel.Config.Endpoint
: overrideTarget.Trim();
if (string.IsNullOrWhiteSpace(target))
{
throw new ChannelTestSendValidationException("Channel target is required. Provide 'target' or configure channel.config.target/endpoint.");
}
return target;
}
private static NotifyDeliveryRendered CreateFallbackPreview(ChannelTestPreviewContext context)
{
var format = MapFormat(context.Channel.Type);
var title = context.Request.Title ?? $"Stella Ops Notify Test ({context.Channel.Name})";
var body = context.Request.Body ?? BuildDefaultBody(context.Channel, context.Timestamp);
var summary = context.Request.Summary ?? $"Preview generated for {context.Channel.Type} destination.";
return NotifyDeliveryRendered.Create(
context.Channel.Type,
format,
context.Target,
title,
body,
summary,
context.Request.TextBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
}
private static NotifyDeliveryRendered EnsureBodyHash(NotifyDeliveryRendered preview)
{
if (!string.IsNullOrWhiteSpace(preview.BodyHash))
{
return preview;
}
var hash = ChannelTestPreviewUtilities.ComputeBodyHash(preview.Body);
return NotifyDeliveryRendered.Create(
preview.ChannelType,
preview.Format,
preview.Target,
preview.Title,
preview.Body,
preview.Summary,
preview.TextBody,
preview.Locale,
hash,
preview.Attachments);
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelTestPreviewContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId
};
foreach (var pair in context.Request.Metadata)
{
metadata[pair.Key] = pair.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
private static NotifyDeliveryFormat MapFormat(NotifyChannelType type)
=> type switch
{
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
_ => NotifyDeliveryFormat.Json
};
private static string BuildDefaultBody(NotifyChannel channel, DateTimeOffset timestamp)
{
return $"This is a Stella Ops Notify test message for channel '{channel.Name}' " +
$"({channel.ChannelId}, type {channel.Type}). Generated at {timestamp:O}.";
}
private static IReadOnlyDictionary<string, string> NormalizeInputMetadata(IDictionary<string, string>? source)
{
if (source is null || source.Count == 0)
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
var result = new Dictionary<string, string>(source.Count, StringComparer.Ordinal);
foreach (var pair in source)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
result[pair.Key.Trim()] = pair.Value.Trim();
}
return result;
}
private static IReadOnlyList<string> NormalizeAttachments(IList<string>? attachments)
{
if (attachments is null || attachments.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>(attachments.Count);
foreach (var attachment in attachments)
{
if (string.IsNullOrWhiteSpace(attachment))
{
continue;
}
list.Add(attachment.Trim());
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? TrimToLowerInvariant(string? value)
{
var trimmed = TrimToNull(value);
return trimmed?.ToLowerInvariant();
}
}
internal sealed class ChannelTestSendValidationException : Exception
{
public ChannelTestSendValidationException(string message)
: base(message)
{
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Services;
internal sealed class NotifySchemaMigrationService
{
public NotifyRule UpgradeRule(JsonNode json)
=> NotifySchemaMigration.UpgradeRule(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyChannel UpgradeChannel(JsonNode json)
=> NotifySchemaMigration.UpgradeChannel(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyTemplate UpgradeTemplate(JsonNode json)
=> NotifySchemaMigration.UpgradeTemplate(json ?? throw new ArgumentNullException(nameof(json)));
}

View File

@@ -0,0 +1,28 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,360 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.WebService.Storage.InMemory;
internal static class InMemoryStorageModule
{
public static IServiceCollection AddInMemoryNotifyStorage(this IServiceCollection services)
{
services.AddSingleton<InMemoryStore>();
services.AddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
services.AddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
services.AddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
services.AddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
services.AddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
services.AddSingleton<INotifyLockRepository, InMemoryLockRepository>();
services.AddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
return services;
}
private sealed class InMemoryStore
{
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> Rules { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> Channels { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyTemplate>> Templates { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDelivery>> Deliveries { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDigestDocument>> Digests { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentDictionary<string, LockEntry>> Locks { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, ConcurrentQueue<NotifyAuditEntryDocument>> AuditEntries { get; } = new(StringComparer.Ordinal);
}
private sealed class InMemoryRuleRepository : INotifyRuleRepository
{
private readonly InMemoryStore _store;
public InMemoryRuleRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
var map = _store.Rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
map[rule.RuleId] = rule;
return Task.CompletedTask;
}
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_store.Rules.TryGetValue(tenantId, out var map) && map.TryGetValue(ruleId, out var rule))
{
return Task.FromResult<NotifyRule?>(rule);
}
return Task.FromResult<NotifyRule?>(null);
}
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_store.Rules.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyRule>>(map.Values.OrderBy(static r => r.RuleId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
}
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
if (_store.Rules.TryGetValue(tenantId, out var map))
{
map.TryRemove(ruleId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryChannelRepository : INotifyChannelRepository
{
private readonly InMemoryStore _store;
public InMemoryChannelRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
var map = _store.Channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
map[channel.ChannelId] = channel;
return Task.CompletedTask;
}
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_store.Channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
{
return Task.FromResult<NotifyChannel?>(channel);
}
return Task.FromResult<NotifyChannel?>(null);
}
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_store.Channels.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.OrderBy(static c => c.ChannelId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
}
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_store.Channels.TryGetValue(tenantId, out var map))
{
map.TryRemove(channelId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
{
private readonly InMemoryStore _store;
public InMemoryTemplateRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
var map = _store.Templates.GetOrAdd(template.TenantId, _ => new ConcurrentDictionary<string, NotifyTemplate>(StringComparer.Ordinal));
map[template.TemplateId] = template;
return Task.CompletedTask;
}
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map) && map.TryGetValue(templateId, out var template))
{
return Task.FromResult<NotifyTemplate?>(template);
}
return Task.FromResult<NotifyTemplate?>(null);
}
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(map.Values.OrderBy(static t => t.TemplateId, StringComparer.Ordinal).ToList());
}
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(Array.Empty<NotifyTemplate>());
}
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
if (_store.Templates.TryGetValue(tenantId, out var map))
{
map.TryRemove(templateId, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly InMemoryStore _store;
public InMemoryDeliveryRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
=> UpdateAsync(delivery, cancellationToken);
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
var map = _store.Deliveries.GetOrAdd(delivery.TenantId, _ => new ConcurrentDictionary<string, NotifyDelivery>(StringComparer.Ordinal));
map[delivery.DeliveryId] = delivery;
return Task.CompletedTask;
}
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
if (_store.Deliveries.TryGetValue(tenantId, out var map) && map.TryGetValue(deliveryId, out var delivery))
{
return Task.FromResult<NotifyDelivery?>(delivery);
}
return Task.FromResult<NotifyDelivery?>(null);
}
public Task<NotifyDeliveryQueryResult> QueryAsync(string tenantId, DateTimeOffset? since, string? status, int? limit, string? continuationToken = null, CancellationToken cancellationToken = default)
{
if (!_store.Deliveries.TryGetValue(tenantId, out var map))
{
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
}
var query = map.Values.AsEnumerable();
if (since.HasValue)
{
query = query.Where(d => d.CreatedAt >= since.Value);
}
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var parsed))
{
query = query.Where(d => d.Status == parsed);
}
query = query.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.DeliveryId, StringComparer.Ordinal);
if (limit.HasValue && limit.Value > 0)
{
query = query.Take(limit.Value);
}
var items = query.ToList();
return Task.FromResult(new NotifyDeliveryQueryResult(items, ContinuationToken: null));
}
}
private sealed class InMemoryDigestRepository : INotifyDigestRepository
{
private readonly InMemoryStore _store;
public InMemoryDigestRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
{
var map = _store.Digests.GetOrAdd(document.TenantId, _ => new ConcurrentDictionary<string, NotifyDigestDocument>(StringComparer.Ordinal));
map[document.ActionKey] = document;
return Task.CompletedTask;
}
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
if (_store.Digests.TryGetValue(tenantId, out var map) && map.TryGetValue(actionKey, out var document))
{
return Task.FromResult<NotifyDigestDocument?>(document);
}
return Task.FromResult<NotifyDigestDocument?>(null);
}
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
if (_store.Digests.TryGetValue(tenantId, out var map))
{
map.TryRemove(actionKey, out _);
}
return Task.CompletedTask;
}
}
private sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly InMemoryStore _store;
public InMemoryLockRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var map = _store.Locks.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, LockEntry>(StringComparer.Ordinal));
var now = DateTimeOffset.UtcNow;
var entry = map.GetOrAdd(resource, _ => new LockEntry(owner, now, now.Add(ttl)));
lock (entry)
{
if (entry.Owner == owner || entry.ExpiresAt <= now)
{
entry.Owner = owner;
entry.AcquiredAt = now;
entry.ExpiresAt = now.Add(ttl);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
if (_store.Locks.TryGetValue(tenantId, out var map) && map.TryGetValue(resource, out var entry))
{
lock (entry)
{
if (entry.Owner == owner)
{
map.TryRemove(resource, out _);
}
}
}
return Task.CompletedTask;
}
}
private sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly InMemoryStore _store;
public InMemoryAuditRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
var queue = _store.AuditEntries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<NotifyAuditEntryDocument>());
queue.Enqueue(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
if (!_store.AuditEntries.TryGetValue(tenantId, out var queue))
{
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
}
var items = queue
.Where(entry => !since.HasValue || entry.Timestamp >= since.Value)
.OrderByDescending(entry => entry.Timestamp)
.ThenBy(entry => entry.Id.ToString(), StringComparer.Ordinal)
.ToList();
if (limit.HasValue && limit.Value > 0 && items.Count > limit.Value)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}
private sealed class LockEntry
{
public LockEntry(string owner, DateTimeOffset acquiredAt, DateTimeOffset expiresAt)
{
Owner = owner;
AcquiredAt = acquiredAt;
ExpiresAt = expiresAt;
}
public string Owner { get; set; }
public DateTimeOffset AcquiredAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
}

View File

@@ -0,0 +1,2 @@
# Notify WebService Task Board (Sprint 15)
> Archived 2025-10-26 — control plane now lives in `src/Notifier/StellaOps.Notifier` (Sprints 3840).