Add channel test providers for Email, Slack, Teams, and Webhook
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented EmailChannelTestProvider to generate email preview payloads.
- Implemented SlackChannelTestProvider to create Slack message previews.
- Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews.
- Implemented WebhookChannelTestProvider to create webhook payloads.
- Added INotifyChannelTestProvider interface for channel-specific preview generation.
- Created ChannelTestPreviewContracts for request and response models.
- Developed NotifyChannelTestService to handle test send requests and generate previews.
- Added rate limit policies for test sends and delivery history.
- Implemented unit tests for service registration and binding.
- Updated project files to include necessary dependencies and configurations.
This commit is contained in:
2025-10-19 23:29:34 +03:00
parent 8e7ce55542
commit 5fd4032c7c
239 changed files with 17245 additions and 3155 deletions

View File

@@ -16,7 +16,7 @@ Minimal API host wiring configuration, storage, plugin routines, and job endpoin
- GET /jobs/definitions/{kind}/runs?limit= -> recent runs or 404 if kind unknown.
- GET /jobs/active -> currently running.
- POST /jobs/{*jobKind} with {trigger?,parameters?} -> 202 Accepted (Location:/jobs/{runId}) | 404 | 409 | 423.
- PluginHost defaults: BaseDirectory = solution root; PluginsDirectory = "PluginBinaries"; SearchPatterns += "StellaOps.Concelier.Plugin.*.dll"; EnsureDirectoryExists = true.
- PluginHost defaults: BaseDirectory = solution root; PluginsDirectory = "StellaOps.Concelier.PluginBinaries"; SearchPatterns += "StellaOps.Concelier.Plugin.*.dll"; EnsureDirectoryExists = true.
## Participants
- Core job system; Storage.Mongo; Source.Common HTTP clients; Exporter and Connector plugin routines discover/register jobs.
## Interfaces & contracts

View File

@@ -0,0 +1,181 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Services;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class MirrorEndpointExtensions
{
private const string IndexScope = "index";
private const string DownloadScope = "download";
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
{
app.MapGet("/concelier/exports/index.json", async (
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return Results.NotFound();
}
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
if (!locator.TryResolveIndex(out var path, out _))
{
return Results.NotFound();
}
return await WriteFileAsync(path, context.Response, "application/json").ConfigureAwait(false);
});
app.MapGet("/concelier/exports/{**relativePath}", async (
string? relativePath,
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(relativePath))
{
return Results.NotFound();
}
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
{
return Results.NotFound();
}
var domain = FindDomain(mirrorOptions, domainId);
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var contentType = ResolveContentType(path);
return await WriteFileAsync(path, context.Response, contentType).ConfigureAwait(false);
});
}
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
{
if (domainId is null)
{
return null;
}
foreach (var candidate in mirrorOptions.Domains)
{
if (candidate is null)
{
continue;
}
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
{
result = Results.Empty;
if (!requireAuthentication)
{
return true;
}
if (!enforceAuthority || !authorityConfigured)
{
return true;
}
if (context.User?.Identity?.IsAuthenticated == true)
{
return true;
}
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
return false;
}
private static Task<IResult> WriteFileAsync(string path, HttpResponse response, string contentType)
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists)
{
return Task.FromResult(Results.NotFound());
}
var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read | FileShare.Delete);
response.Headers.CacheControl = "public, max-age=60";
response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
response.ContentLength = fileInfo.Length;
return Task.FromResult(Results.Stream(stream, contentType));
}
private static string ResolveContentType(string path)
{
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "application/jose+json";
}
return "application/octet-stream";
}
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
{
if (retryAfter is null)
{
return;
}
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Options;
@@ -12,6 +13,8 @@ public sealed class ConcelierOptions
public TelemetryOptions Telemetry { get; set; } = new();
public AuthorityOptions Authority { get; set; } = new();
public MirrorOptions Mirror { get; set; } = new();
public sealed class StorageOptions
{
@@ -99,4 +102,37 @@ public sealed class ConcelierOptions
public TimeSpan? OfflineCacheTolerance { get; set; }
}
}
public sealed class MirrorOptions
{
public bool Enabled { get; set; }
public string ExportRoot { get; set; } = System.IO.Path.Combine("exports", "json");
public string? ActiveExportId { get; set; }
public string LatestDirectoryName { get; set; } = "latest";
public string MirrorDirectoryName { get; set; } = "mirror";
public bool RequireAuthentication { get; set; }
public int MaxIndexRequestsPerHour { get; set; } = 600;
public IList<MirrorDomainOptions> Domains { get; } = new List<MirrorDomainOptions>();
[JsonIgnore]
public string ExportRootAbsolute { get; internal set; } = string.Empty;
}
public sealed class MirrorDomainOptions
{
public string Id { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public bool RequireAuthentication { get; set; }
public int MaxDownloadRequestsPerHour { get; set; } = 1200;
}
}

View File

@@ -42,5 +42,31 @@ public static class ConcelierOptionsPostConfigure
authority.ClientSecret = secret;
}
options.Mirror ??= new ConcelierOptions.MirrorOptions();
var mirror = options.Mirror;
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
mirror.ExportRoot = Path.Combine("exports", "json");
}
var resolvedRoot = mirror.ExportRoot;
if (!Path.IsPathRooted(resolvedRoot))
{
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
}
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
mirror.LatestDirectoryName = "latest";
}
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
{
mirror.MirrorDirectoryName = "mirror";
}
}
}

View File

@@ -130,6 +130,9 @@ public static class ConcelierOptionsValidator
throw new InvalidOperationException("Telemetry OTLP header names must be non-empty.");
}
}
options.Mirror ??= new ConcelierOptions.MirrorOptions();
ValidateMirror(options.Mirror);
}
private static void NormalizeList(IList<string> values, bool toLower)
@@ -186,4 +189,57 @@ public static class ConcelierOptionsValidator
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
}
private static void ValidateMirror(ConcelierOptions.MirrorOptions mirror)
{
if (mirror.MaxIndexRequestsPerHour < 0)
{
throw new InvalidOperationException("Mirror maxIndexRequestsPerHour must be greater than or equal to zero.");
}
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
throw new InvalidOperationException("Mirror exportRoot must be configured.");
}
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
throw new InvalidOperationException("Mirror export root could not be resolved.");
}
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
throw new InvalidOperationException("Mirror latestDirectoryName must be provided.");
}
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
{
throw new InvalidOperationException("Mirror mirrorDirectoryName must be provided.");
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var domain in mirror.Domains)
{
if (string.IsNullOrWhiteSpace(domain.Id))
{
throw new InvalidOperationException("Mirror domain id must be provided.");
}
var normalized = domain.Id.Trim();
if (!seen.Add(normalized))
{
throw new InvalidOperationException($"Mirror domain id '{normalized}' is duplicated.");
}
if (domain.MaxDownloadRequestsPerHour < 0)
{
throw new InvalidOperationException($"Mirror domain '{normalized}' maxDownloadRequestsPerHour must be greater than or equal to zero.");
}
}
if (mirror.Enabled && mirror.Domains.Count == 0)
{
throw new InvalidOperationException("Mirror distribution requires at least one domain when enabled.");
}
}
}

View File

@@ -4,8 +4,9 @@ using System.Text;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -13,7 +14,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
@@ -23,6 +25,7 @@ using StellaOps.Concelier.WebService.Extensions;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Filters;
using StellaOps.Concelier.WebService.Services;
using Serilog.Events;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
@@ -62,10 +65,15 @@ builder.Services.AddOptions<ConcelierOptions>()
.ValidateOnStart();
builder.ConfigureConcelierTelemetry(concelierOptions);
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<MirrorRateLimiter>();
builder.Services.AddSingleton<MirrorFileLocator>();
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
@@ -174,9 +182,58 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
"Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout.");
}
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
jsonOptions.Converters.Add(new JsonStringEnumConverter());
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,
IAdvisoryEventLog eventLog,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
{
return Results.BadRequest("vulnerabilityKey must be provided.");
}
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
{
return Results.NotFound();
}
var response = new
{
replay.VulnerabilityKey,
replay.AsOf,
Statements = replay.Statements.Select(statement => new
{
statement.StatementId,
statement.VulnerabilityKey,
statement.AdvisoryKey,
statement.Advisory,
StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()),
statement.AsOf,
statement.RecordedAt,
InputDocumentIds = statement.InputDocumentIds
}).ToArray(),
Conflicts = replay.Conflicts.Select(conflict => new
{
conflict.ConflictId,
conflict.VulnerabilityKey,
conflict.StatementIds,
ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()),
conflict.AsOf,
conflict.RecordedAt,
Details = conflict.CanonicalJson
}).ToArray()
};
return JsonResult(response);
});
var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true;
if (loggingEnabled)
@@ -660,8 +717,9 @@ static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string con
{
var pluginOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot,
PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "PluginBinaries"),
BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot,
PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"),
PrimaryPrefix = "StellaOps.Concelier",
EnsureDirectoryExists = true,
RecursiveSearch = false,
};

View File

@@ -0,0 +1,184 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorFileLocator
{
private readonly IOptionsMonitor<ConcelierOptions> _options;
private readonly ILogger<MirrorFileLocator> _logger;
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
{
fullPath = null;
exportId = null;
domainId = null;
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirror.Enabled)
{
return false;
}
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
{
return false;
}
var sanitized = SanitizeRelativePath(relativePath);
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
{
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
}
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var candidate = Combine(exportDirectory, sanitized);
if (!CandidateWithinExport(exportDirectory, candidate))
{
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
return false;
}
if (!File.Exists(candidate))
{
return false;
}
// Extract domain id from path mirror/<domain>/...
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2)
{
domainId = segments[1];
}
fullPath = candidate;
return true;
}
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
return false;
}
var root = mirror.ExportRootAbsolute;
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
? mirror.LatestDirectoryName
: mirror.ActiveExportId!;
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
try
{
var directories = Directory.Exists(root)
? Directory.GetDirectories(root)
: Array.Empty<string>();
Array.Sort(directories, StringComparer.Ordinal);
Array.Reverse(directories);
foreach (var directory in directories)
{
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
}
return false;
}
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(segment))
{
return false;
}
var candidate = Path.Combine(root, segment);
if (!Directory.Exists(candidate))
{
return false;
}
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
if (!Directory.Exists(mirrorPath))
{
return false;
}
exportDirectory = candidate;
exportId = segment;
return true;
}
private static string SanitizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
return trimmed;
}
private static string Combine(string root, string relativePath)
{
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return Path.GetFullPath(root);
}
var combinedRelative = Path.Combine(segments);
return Path.GetFullPath(Path.Combine(root, combinedRelative));
}
private static bool CandidateWithinExport(string exportDirectory, string candidate)
{
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var candidatePath = Path.GetFullPath(candidate);
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorRateLimiter
{
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
{
retryAfter = null;
if (limit <= 0 || limit == int.MaxValue)
{
return true;
}
var key = CreateKey(domainId, scope);
var now = _timeProvider.GetUtcNow();
var counter = _cache.Get<Counter>(key);
if (counter is null || now - counter.WindowStart >= Window)
{
counter = new Counter(now, 0);
}
if (counter.Count >= limit)
{
var windowEnd = counter.WindowStart + Window;
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return false;
}
counter = counter with { Count = counter.Count + 1 };
var absoluteExpiration = counter.WindowStart + Window;
_cache.Set(key, counter, absoluteExpiration);
return true;
}
private static string CreateKey(string domainId, string scope)
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
{
state.domainId.AsSpan().CopyTo(span);
span[state.domainId.Length] = '|';
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
});
private sealed record Counter(DateTimeOffset WindowStart, int Count);
}

View File

@@ -1,18 +1,19 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|Bind & validate ConcelierOptions|BE-Base|WebService|DONE options bound/validated with failure logging.|
|Mongo service wiring|BE-Base|Storage.Mongo|DONE wiring delegated to `AddMongoStorage`.|
|Bootstrapper execution on start|BE-Base|Storage.Mongo|DONE startup calls `MongoBootstrapper.InitializeAsync`.|
|Plugin host options finalization|BE-Base|Plugins|DONE default plugin directories/search patterns configured.|
|Jobs API contract tests|QA|Core|DONE WebServiceEndpointsTests now cover success payloads, filtering, and trigger outcome mapping.|
|Health/Ready probes|DevOps|Ops|DONE `/health` and `/ready` endpoints implemented.|
|Serilog + OTEL integration hooks|BE-Base|Observability|DONE `TelemetryExtensions` wires Serilog + OTEL with configurable exporters.|
|Register built-in jobs (sources/exporters)|BE-Base|Core|DONE AddBuiltInConcelierJobs adds fallback scheduler definitions for core connectors and exporters via reflection.|
|HTTP problem details consistency|BE-Base|WebService|DONE API errors now emit RFC7807 responses with trace identifiers and typed problem categories.|
|Request logging and metrics|BE-Base|Observability|DONE Serilog request logging enabled with enriched context and web.jobs counters published via OpenTelemetry.|
|Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.|
|Batch job definition last-run lookup|BE-Base|Core|DONE definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.|
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|FEEDWEB-EVENTS-07-001 Advisory event replay API|Concelier WebService Guild|FEEDCORE-ENGINE-07-001|**DONE (2025-10-19)** Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint with optional `asOf`, hex hashes, and conflict payloads; integration covered via `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj`.|
|Bind & validate ConcelierOptions|BE-Base|WebService|DONE options bound/validated with failure logging.|
|Mongo service wiring|BE-Base|Storage.Mongo|DONE wiring delegated to `AddMongoStorage`.|
|Bootstrapper execution on start|BE-Base|Storage.Mongo|DONE startup calls `MongoBootstrapper.InitializeAsync`.|
|Plugin host options finalization|BE-Base|Plugins|DONE default plugin directories/search patterns configured.|
|Jobs API contract tests|QA|Core|DONE WebServiceEndpointsTests now cover success payloads, filtering, and trigger outcome mapping.|
|Health/Ready probes|DevOps|Ops|DONE `/health` and `/ready` endpoints implemented.|
|Serilog + OTEL integration hooks|BE-Base|Observability|DONE `TelemetryExtensions` wires Serilog + OTEL with configurable exporters.|
|Register built-in jobs (sources/exporters)|BE-Base|Core|DONE AddBuiltInConcelierJobs adds fallback scheduler definitions for core connectors and exporters via reflection.|
|HTTP problem details consistency|BE-Base|WebService|DONE API errors now emit RFC7807 responses with trace identifiers and typed problem categories.|
|Request logging and metrics|BE-Base|Observability|DONE Serilog request logging enabled with enriched context and web.jobs counters published via OpenTelemetry.|
|Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.|
|Batch job definition last-run lookup|BE-Base|Core|DONE definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.|
|Add no-cache headers to health/readiness/jobs APIs|BE-Base|WebService|DONE helper applies Cache-Control/Pragma/Expires on all health/ready/jobs endpoints; awaiting automated probe tests once connector fixtures stabilize.|
|Authority configuration parity (FSR1)|DevEx/Concelier|Authority options schema|**DONE (2025-10-10)** Options post-config loads clientSecretFile fallback, validators normalize scopes/audiences, and sample config documents issuer/credential/bypass settings.|
|Document authority toggle & scope requirements|Docs/Concelier|Authority integration|**DOING (2025-10-10)** Quickstart updated with staging flag, client credentials, env overrides; operator guide refresh pending Docs guild review.|
@@ -20,6 +21,7 @@
|Author ops guidance for resilience tuning|Docs/Concelier|Plumb Authority client resilience options|**DONE (2025-10-12)** `docs/21_INSTALL_GUIDE.md` + `docs/ops/concelier-authority-audit-runbook.md` document resilience profiles for connected vs air-gapped installs and reference monitoring cues.|
|Document authority bypass logging patterns|Docs/Concelier|FSR3 logging|**DONE (2025-10-12)** Updated operator guides clarify `Concelier.Authorization.Audit` fields (route/status/subject/clientId/scopes/bypass/remote) and SIEM triggers.|
|Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**TODO** Point Concelier source/exporter build outputs to `StellaOps.Concelier.PluginBinaries`, update PluginHost defaults/search patterns to match, ensure Offline Kit packaging/tests expect the new folder, and document migration guidance for operators.|
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**DONE (2025-10-19)** Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, config/docs refreshed, and `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore` covers the change.|
|Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|
|CONCELIER-WEB-08-201 Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|TODO Add domain-scoped mirror configuration (`*.stella-ops.org`), expose signed export index/download APIs with quota and auth, and document sync workflow for downstream Concelier instances.|
|CONCELIER-WEB-08-201 Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|DOING (2025-10-19) HTTP endpoints wired (`/concelier/exports/index.json`, `/concelier/exports/mirror/*`), mirror options bound/validated, and integration tests added; pending auth docs + smoke in ops handbook.|
|Wave 0B readiness checkpoint|Team WebService & Authority|Wave0A completion|BLOCKED (2025-10-19) FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|