using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using MongoDB.Bson; using MongoDB.Driver; using StellaOps.Concelier.Core.Events; using StellaOps.Concelier.Core.Jobs; using StellaOps.Concelier.Core.Observations; using StellaOps.Concelier.Core.Linksets; using StellaOps.Concelier.Models; using StellaOps.Concelier.WebService.Diagnostics; using ServiceStatus = StellaOps.Concelier.WebService.Diagnostics.ServiceStatus; using Serilog; using StellaOps.Concelier.Merge; using StellaOps.Concelier.Merge.Services; 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 StellaOps.Concelier.WebService.Telemetry; using Serilog.Events; using StellaOps.Plugin.DependencyInjection; using StellaOps.Plugin.Hosting; using StellaOps.Configuration; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Auth.ServerIntegration; using StellaOps.Aoc; using StellaOps.Concelier.WebService.Deprecation; using StellaOps.Aoc.AspNetCore.Routing; using StellaOps.Aoc.AspNetCore.Results; using StellaOps.Concelier.WebService.Contracts; using StellaOps.Concelier.WebService.Results; using StellaOps.Concelier.Core.Aoc; using StellaOps.Concelier.Core.Raw; using StellaOps.Concelier.RawModels; using StellaOps.Concelier.Storage.Mongo; using StellaOps.Concelier.Storage.Mongo.Advisories; using StellaOps.Concelier.Storage.Mongo.Aliases; using StellaOps.Concelier.Storage.Postgres; using StellaOps.Provenance.Mongo; using StellaOps.Concelier.Core.Attestation; using AttestationClaims = StellaOps.Concelier.Core.Attestation.AttestationClaims; using StellaOps.Concelier.Storage.Mongo.Orchestrator; using System.Diagnostics.Metrics; using StellaOps.Concelier.Models.Observations; namespace StellaOps.Concelier.WebService { public partial class Program { private const string JobsPolicyName = "Concelier.Jobs.Trigger"; private const string ObservationsPolicyName = "Concelier.Observations.Read"; private const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest"; private const string AdvisoryReadPolicyName = "Concelier.Advisories.Read"; private const string AocVerifyPolicyName = "Concelier.Aoc.Verify"; public const string TenantHeaderName = "X-Stella-Tenant"; public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // For test/CI runs, allow injecting a minimal config before options bind. #pragma warning disable ASP0013 // permitted here for test-only override path builder.Host.ConfigureAppConfiguration((context, cfg) => { if (context.HostingEnvironment.IsEnvironment("Testing")) { cfg.AddInMemoryCollection(new Dictionary { {"Concelier:Storage:Dsn", Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "mongodb://localhost:27017/test-health"}, {"Concelier:Storage:Driver", "mongo"}, {"Concelier:Storage:CommandTimeoutSeconds", "30"}, {"Concelier:Telemetry:Enabled", "false"} }); } }); #pragma warning restore ASP0013 var JsonOptions = CreateJsonOptions(); builder.Configuration.AddStellaOpsDefaults(options => { options.BasePath = builder.Environment.ContentRootPath; options.EnvironmentPrefix = "CONCELIER_"; options.ConfigureBuilder = configurationBuilder => { configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml")); }; }); var contentRootPath = builder.Environment.ContentRootPath; // For Testing we allow pre-bound options injected via DI to override BindOptions. ConcelierOptions concelierOptions; if (builder.Environment.IsEnvironment("Testing")) { // Allow a fully pre-bound options instance to be supplied by the test host. #pragma warning disable ASP0000 // test-only: create provider to fetch pre-bound options using var tempProvider = builder.Services.BuildServiceProvider(); #pragma warning restore ASP0000 concelierOptions = tempProvider.GetService>()?.Value ?? new ConcelierOptions { Storage = new ConcelierOptions.StorageOptions { Dsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "mongodb://localhost:27017/test-health", Driver = "mongo", CommandTimeoutSeconds = 30 }, Telemetry = new ConcelierOptions.TelemetryOptions { Enabled = false } }; concelierOptions.Storage ??= new ConcelierOptions.StorageOptions(); concelierOptions.Storage.Dsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "mongodb://localhost:27017/orch-tests"; concelierOptions.Storage.Driver = "mongo"; concelierOptions.Storage.CommandTimeoutSeconds = concelierOptions.Storage.CommandTimeoutSeconds <= 0 ? 30 : concelierOptions.Storage.CommandTimeoutSeconds; ConcelierOptionsPostConfigure.Apply(concelierOptions, contentRootPath); // Skip validation in Testing to allow factory-provided wiring. } else { concelierOptions = builder.Configuration.BindOptions(postConfigure: (opts, _) => { var testDsn = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN"); if (string.IsNullOrWhiteSpace(opts.Storage.Dsn) && !string.IsNullOrWhiteSpace(testDsn)) { opts.Storage.Dsn = testDsn; } ConcelierOptionsPostConfigure.Apply(opts, contentRootPath); var skipValidation = string.Equals(Environment.GetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION"), "1", StringComparison.OrdinalIgnoreCase); if (!skipValidation) { ConcelierOptionsValidator.Validate(opts); } }); } // Register the chosen options instance so downstream services/tests share it. builder.Services.AddSingleton(concelierOptions); builder.Services.AddSingleton>(_ => Microsoft.Extensions.Options.Options.Create(concelierOptions)); builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto); builder.ConfigureConcelierTelemetry(concelierOptions); builder.Services.TryAddSingleton(_ => TimeProvider.System); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var isTesting = builder.Environment.IsEnvironment("Testing"); var mongoBypass = isTesting || string.Equals( Environment.GetEnvironmentVariable("CONCELIER_BYPASS_MONGO"), "1", StringComparison.OrdinalIgnoreCase); if (!isTesting) { builder.Services.AddMongoStorage(storageOptions => { storageOptions.ConnectionString = concelierOptions.Storage.Dsn; storageOptions.DatabaseName = concelierOptions.Storage.Database; storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds); }); } else { // In test host we entirely bypass Mongo validation/bootstrapping; tests inject fakes. builder.Services.RemoveAll(); builder.Services.RemoveAll(); } // Add PostgreSQL storage for LNM linkset cache if configured. // This provides a PostgreSQL-backed implementation of IAdvisoryLinksetStore for the read-through cache. if (concelierOptions.PostgresStorage is { Enabled: true } postgresOptions) { builder.Services.AddConcelierPostgresStorage(pgOptions => { pgOptions.ConnectionString = postgresOptions.ConnectionString; pgOptions.CommandTimeoutSeconds = postgresOptions.CommandTimeoutSeconds; pgOptions.MaxPoolSize = postgresOptions.MaxPoolSize; pgOptions.MinPoolSize = postgresOptions.MinPoolSize; pgOptions.ConnectionIdleLifetimeSeconds = postgresOptions.ConnectionIdleLifetimeSeconds; pgOptions.Pooling = postgresOptions.Pooling; pgOptions.SchemaName = postgresOptions.SchemaName; pgOptions.AutoMigrate = postgresOptions.AutoMigrate; pgOptions.MigrationsPath = postgresOptions.MigrationsPath; }); } builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("advisoryObservationEvents")) .PostConfigure(options => { options.Subject ??= "concelier.advisory.observation.updated.v1"; options.Stream ??= "CONCELIER_OBS"; options.Transport = string.IsNullOrWhiteSpace(options.Transport) ? "mongo" : options.Transport; }) .ValidateOnStart(); builder.Services.AddConcelierAocGuards(); builder.Services.AddConcelierLinksetMappers(); builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Register read-through cache service for LNM linksets (CONCELIER-AIAI-31-002) // When Postgres is enabled, uses it as cache backing; otherwise builds from observations directly builder.Services.AddSingleton(sp => { var observations = sp.GetRequiredService(); var telemetry = sp.GetRequiredService(); var timeProvider = sp.GetRequiredService(); // Get Postgres cache if available (registered by AddConcelierPostgresStorage) var cacheLookup = sp.GetService() as IAdvisoryLinksetLookup; var cacheSink = sp.GetService() as IAdvisoryLinksetSink; return new ReadThroughLinksetCacheService( observations, telemetry, timeProvider, cacheLookup, cacheSink); }); // Use read-through cache as the primary linkset lookup builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddAdvisoryRawServices(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions(); if (!features.NoMergeEnabled) { #pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Legacy merge service is intentionally supported behind a feature toggle. builder.Services.AddMergeModule(builder.Configuration); #pragma warning restore CS0618, CONCELIER0001, CONCELIER0002 } builder.Services.AddJobScheduler(); builder.Services.AddBuiltInConcelierJobs(); builder.Services.PostConfigure(options => { if (features.NoMergeEnabled) { options.Definitions.Remove("merge:reconcile"); return; } if (features.MergeJobAllowlist is { Count: > 0 }) { var allowMergeJob = features.MergeJobAllowlist.Any(value => string.Equals(value, "merge:reconcile", StringComparison.OrdinalIgnoreCase)); if (!allowMergeJob) { options.Definitions.Remove("merge:reconcile"); } } }); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new StellaOps.Concelier.WebService.Diagnostics.ServiceStatus(sp.GetRequiredService())); builder.Services.AddAocGuard(); var authorityConfigured = concelierOptions.Authority is { Enabled: true }; if (authorityConfigured) { builder.Services.AddStellaOpsAuthClient(clientOptions => { clientOptions.Authority = concelierOptions.Authority.Issuer; clientOptions.ClientId = concelierOptions.Authority.ClientId ?? string.Empty; clientOptions.ClientSecret = concelierOptions.Authority.ClientSecret; clientOptions.HttpTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); clientOptions.DefaultScopes.Clear(); foreach (var scope in concelierOptions.Authority.ClientScopes) { clientOptions.DefaultScopes.Add(scope); } var resilience = concelierOptions.Authority.Resilience ?? new ConcelierOptions.AuthorityOptions.ResilienceOptions(); if (resilience.EnableRetries.HasValue) { clientOptions.EnableRetries = resilience.EnableRetries.Value; } if (resilience.RetryDelays is { Count: > 0 }) { clientOptions.RetryDelays.Clear(); foreach (var delay in resilience.RetryDelays) { clientOptions.RetryDelays.Add(delay); } } if (resilience.AllowOfflineCacheFallback.HasValue) { clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value; } if (resilience.OfflineCacheTolerance.HasValue) { clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value; } }); if (string.IsNullOrWhiteSpace(concelierOptions.Authority.TestSigningSecret)) { builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: null, configure: resourceOptions => { resourceOptions.Authority = concelierOptions.Authority.Issuer; resourceOptions.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds); resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds); if (!string.IsNullOrWhiteSpace(concelierOptions.Authority.MetadataAddress)) { resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress; } foreach (var audience in concelierOptions.Authority.Audiences) { resourceOptions.Audiences.Add(audience); } foreach (var scope in concelierOptions.Authority.RequiredScopes) { resourceOptions.RequiredScopes.Add(scope); } foreach (var network in concelierOptions.Authority.BypassNetworks) { resourceOptions.BypassNetworks.Add(network); } }); } else { builder.Services .AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme) .AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options => { options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(concelierOptions.Authority.TestSigningSecret!)), ValidateIssuer = true, ValidIssuer = concelierOptions.Authority.Issuer, ValidateAudience = concelierOptions.Authority.Audiences.Count > 0, ValidAudiences = concelierOptions.Authority.Audiences, ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds), NameClaimType = StellaOpsClaimTypes.Subject, RoleClaimType = ClaimTypes.Role }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var logger = context.HttpContext.RequestServices.GetRequiredService>(); string? token = null; if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorizationValues)) { var authorization = authorizationValues.ToString(); if (!string.IsNullOrWhiteSpace(authorization) && authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) && authorization.Length > 7) { token = authorization.Substring("Bearer ".Length).Trim(); } } if (string.IsNullOrEmpty(token)) { token = context.Token; } if (!string.IsNullOrWhiteSpace(token)) { var parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length > 0) { token = parts[^1]; } token = token.Trim().Trim('"'); } if (string.IsNullOrWhiteSpace(token)) { logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path); return Task.CompletedTask; } context.Token = token; return Task.CompletedTask; } }; }); } } builder.Services.AddAuthorization(options => { options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray()); options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView); options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest); options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead); options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify); }); var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath); builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); builder.Services.AddEndpointsApiExplorer(); var app = builder.Build(); app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret)); if (features.NoMergeEnabled) { app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active."); } var resolvedConcelierOptions = app.Services.GetRequiredService>().Value; var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions(); authorityConfigured = resolvedAuthority.Enabled; var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback; var requiredTenants = (resolvedAuthority.RequiredTenants ?? Array.Empty()) .Select(static tenant => tenant?.Trim().ToLowerInvariant()) .Where(static tenant => !string.IsNullOrWhiteSpace(tenant)) .Distinct(StringComparer.Ordinal) .ToImmutableHashSet(StringComparer.Ordinal); var enforceTenantAllowlist = !requiredTenants.IsEmpty; if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) { app.Logger.LogWarning( "Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout."); } if (authorityConfigured) { app.UseAuthentication(); app.UseAuthorization(); } // Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001) app.UseDeprecationHeaders(); app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority); app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) => { var (payload, etag) = provider.GetDocument(); if (context.Request.Headers.IfNoneMatch.Count > 0) { foreach (var candidate in context.Request.Headers.IfNoneMatch) { if (Matches(candidate, etag)) { context.Response.Headers.ETag = etag; context.Response.Headers.CacheControl = "public, max-age=300, immutable"; return Results.StatusCode(StatusCodes.Status304NotModified); } } } context.Response.Headers.ETag = etag; context.Response.Headers.CacheControl = "public, max-age=300, immutable"; return Results.Text(payload, "application/vnd.oai.openapi+json;version=3.1"); static bool Matches(string? candidate, string expected) { if (string.IsNullOrWhiteSpace(candidate)) { return false; } var trimmed = candidate.Trim(); if (string.Equals(trimmed, expected, StringComparison.Ordinal)) { return true; } if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) { var weakValue = trimmed[2..].TrimStart(); return string.Equals(weakValue, expected, StringComparison.Ordinal); } return false; } }).WithName("GetConcelierOpenApiDocument"); var orchestratorGroup = app.MapGroup("/internal/orch"); if (authorityConfigured) { orchestratorGroup.RequireAuthorization(); } orchestratorGroup.MapPost("/registry", async ( HttpContext context, [FromBody] OrchestratorRegistryRequest request, [FromServices] IOrchestratorRegistryStore store, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(request.ConnectorId) || string.IsNullOrWhiteSpace(request.Source)) { return Problem(context, "connectorId and source are required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId and source."); } var now = timeProvider.GetUtcNow(); var record = new OrchestratorRegistryRecord( tenant, request.ConnectorId.Trim(), request.Source.Trim(), request.Capabilities, request.AuthRef, new OrchestratorSchedule( request.Schedule.Cron, string.IsNullOrWhiteSpace(request.Schedule.TimeZone) ? "UTC" : request.Schedule.TimeZone, request.Schedule.MaxParallelRuns, request.Schedule.MaxLagMinutes), new OrchestratorRatePolicy(request.RatePolicy.Rpm, request.RatePolicy.Burst, request.RatePolicy.CooldownSeconds), request.ArtifactKinds, request.LockKey, new OrchestratorEgressGuard(request.EgressGuard.Allowlist, request.EgressGuard.AirgapMode), now, now); await store.UpsertAsync(record, cancellationToken).ConfigureAwait(false); return Results.Accepted(); }).WithName("UpsertOrchestratorRegistry"); orchestratorGroup.MapPost("/heartbeat", async ( HttpContext context, [FromBody] OrchestratorHeartbeatRequest request, [FromServices] IOrchestratorRegistryStore store, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(request.ConnectorId)) { return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId."); } if (request.Sequence < 0) { return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence."); } var timestamp = request.TimestampUtc ?? timeProvider.GetUtcNow(); var heartbeat = new OrchestratorHeartbeatRecord( tenant, request.ConnectorId.Trim(), request.RunId, request.Sequence, request.Status, request.Progress, request.QueueDepth, request.LastArtifactHash, request.LastArtifactKind, request.ErrorCode, request.RetryAfterSeconds, timestamp); await store.AppendHeartbeatAsync(heartbeat, cancellationToken).ConfigureAwait(false); return Results.Accepted(); }).WithName("RecordOrchestratorHeartbeat"); orchestratorGroup.MapPost("/commands", async ( HttpContext context, [FromBody] OrchestratorCommandRequest request, [FromServices] IOrchestratorRegistryStore store, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(request.ConnectorId)) { return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId."); } if (request.Sequence < 0) { return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence."); } var command = new OrchestratorCommandRecord( tenant, request.ConnectorId.Trim(), request.RunId, request.Sequence, request.Command, request.Throttle is null ? null : new OrchestratorThrottleOverride( request.Throttle.Rpm, request.Throttle.Burst, request.Throttle.CooldownSeconds, request.Throttle.ExpiresAt), request.Backfill is null ? null : new OrchestratorBackfillRange(request.Backfill.FromCursor, request.Backfill.ToCursor), DateTimeOffset.UtcNow, request.ExpiresAt); await store.EnqueueCommandAsync(command, cancellationToken).ConfigureAwait(false); return Results.Accepted(); }).WithName("EnqueueOrchestratorCommand"); orchestratorGroup.MapGet("/commands", async ( HttpContext context, [FromQuery] string connectorId, [FromQuery] Guid runId, [FromQuery] long? afterSequence, [FromServices] IOrchestratorRegistryStore store, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(connectorId)) { return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId."); } var commands = await store.GetPendingCommandsAsync(tenant, connectorId.Trim(), runId, afterSequence, cancellationToken).ConfigureAwait(false); return Results.Ok(commands); }).WithName("GetOrchestratorCommands"); var observationsEndpoint = app.MapGet("/concelier/observations", async ( HttpContext context, [FromQuery(Name = "observationId")] string[]? observationIds, [FromQuery(Name = "alias")] string[]? aliases, [FromQuery(Name = "purl")] string[]? purls, [FromQuery(Name = "cpe")] string[]? cpes, [FromQuery(Name = "limit")] int? limit, [FromQuery(Name = "cursor")] string? cursor, [FromServices] IAdvisoryObservationQueryService queryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var normalizedTenant = tenant; var options = new AdvisoryObservationQueryOptions( normalizedTenant, observationIds, aliases, purls, cpes, limit, cursor); var stopwatch = Stopwatch.StartNew(); AdvisoryObservationQueryResult result; try { result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { IngestObservability.IngestErrorsTotal.Add(1, new TagList { {"tenant", normalizedTenant}, {"source", "mixed"}, {"reason", "format"}, {"stage", "ingest"} }); return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message); } var elapsed = stopwatch.Elapsed; IngestObservability.IngestLatencySeconds.Record(elapsed.TotalSeconds, new TagList { {"tenant", normalizedTenant}, {"source", "mixed"}, {"stage", "ingest"} }); var response = new AdvisoryObservationQueryResponse( result.Observations, new AdvisoryObservationLinksetAggregateResponse( result.Linkset.Aliases, result.Linkset.Purls, result.Linkset.Cpes, result.Linkset.References, result.Linkset.Scopes, result.Linkset.Relationships, result.Linkset.Confidence, result.Linkset.Conflicts), result.NextCursor, result.HasMore); return Results.Ok(response); }).WithName("GetConcelierObservations"); const int DefaultLnmPageSize = 50; const int MaxLnmPageSize = 200; app.MapGet("/v1/lnm/linksets", async ( HttpContext context, [FromQuery(Name = "advisoryId")] string? advisoryId, [FromQuery(Name = "source")] string? source, [FromQuery(Name = "page")] int? page, [FromQuery(Name = "pageSize")] int? pageSize, [FromQuery(Name = "includeConflicts")] bool? includeConflicts, [FromServices] IAdvisoryLinksetQueryService queryService, [FromServices] IAdvisoryObservationQueryService observationQueryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var resolvedPage = NormalizePage(page); var resolvedPageSize = NormalizePageSize(pageSize); var advisoryIds = string.IsNullOrWhiteSpace(advisoryId) ? null : new[] { advisoryId.Trim() }; var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() }; var result = await QueryPageAsync( queryService, tenant!, advisoryIds, sources, resolvedPage, resolvedPageSize, cancellationToken).ConfigureAwait(false); var items = new List(result.Items.Count); foreach (var linkset in result.Items) { var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); items.Add(ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false, summary)); } return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); }).WithName("ListLnmLinksets"); app.MapPost("/v1/lnm/linksets/search", async ( HttpContext context, [FromBody] LnmLinksetSearchRequest request, [FromServices] IAdvisoryLinksetQueryService queryService, [FromServices] IAdvisoryObservationQueryService observationQueryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var resolvedPage = NormalizePage(request.Page); var resolvedPageSize = NormalizePageSize(request.PageSize); var advisoryIds = string.IsNullOrWhiteSpace(request.AdvisoryId) ? null : new[] { request.AdvisoryId.Trim() }; var sources = string.IsNullOrWhiteSpace(request.Source) ? null : new[] { request.Source.Trim() }; var result = await QueryPageAsync( queryService, tenant!, advisoryIds, sources, resolvedPage, resolvedPageSize, cancellationToken).ConfigureAwait(false); var items = new List(result.Items.Count); foreach (var linkset in result.Items) { var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); items.Add(ToLnmResponse( linkset, includeConflicts: true, includeTimeline: request.IncludeTimeline, includeObservations: request.IncludeObservations, summary)); } return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); }).WithName("SearchLnmLinksets"); app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( HttpContext context, string advisoryId, [FromQuery(Name = "source")] string? source, [FromServices] IAdvisoryLinksetQueryService queryService, [FromServices] IAdvisoryObservationQueryService observationQueryService, [FromServices] IAdvisoryLinksetStore linksetStore, [FromServices] LinksetCacheTelemetry telemetry, CancellationToken cancellationToken, [FromQuery(Name = "includeConflicts")] bool includeConflicts = true, [FromQuery(Name = "includeObservations")] bool includeObservations = false) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } if (string.IsNullOrWhiteSpace(advisoryId)) { return ConcelierProblemResultFactory.AdvisoryIdRequired(context); } var stopwatch = Stopwatch.StartNew(); var normalizedAdvisoryId = advisoryId.Trim(); var advisoryIds = new[] { normalizedAdvisoryId }; var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() }; // Phase 1: Try cache lookup first (CONCELIER-AIAI-31-002) var cached = await linksetStore .FindByTenantAsync(tenant!, advisoryIds, sources, cursor: null, limit: 1, cancellationToken) .ConfigureAwait(false); AdvisoryLinkset linkset; bool fromCache = false; if (cached.Count > 0) { // Cache hit linkset = cached[0]; fromCache = true; telemetry.RecordHit(tenant, linkset.Source); } else { // Cache miss - rebuild from query service var result = await queryService .QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken) .ConfigureAwait(false); if (result.Linksets.IsDefaultOrEmpty) { return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId); } linkset = result.Linksets[0]; // Write to cache try { await linksetStore.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false); telemetry.RecordWrite(tenant, linkset.Source); } catch (Exception ex) { // Log but don't fail request on cache write errors context.RequestServices.GetRequiredService>() .LogWarning(ex, "Failed to write linkset to cache for {AdvisoryId}", normalizedAdvisoryId); } telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds); } var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false); var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary, cached: fromCache); return Results.Ok(response); }).WithName("GetLnmLinkset"); app.MapGet("/linksets", async ( HttpContext context, [FromQuery(Name = "limit")] int? limit, [FromQuery(Name = "cursor")] string? cursor, [FromServices] IAdvisoryLinksetQueryService queryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var result = await queryService.QueryAsync( new AdvisoryLinksetQueryOptions(tenant, Limit: limit, Cursor: cursor), cancellationToken).ConfigureAwait(false); var payload = new { linksets = result.Linksets.Select(ls => new { AdvisoryId = ls.AdvisoryId, Purls = ls.Normalized?.Purls ?? Array.Empty(), Versions = ls.Normalized?.Versions ?? Array.Empty() }), hasMore = result.HasMore, nextCursor = result.NextCursor }; return Results.Ok(payload); }).WithName("ListLinksetsLegacy"); if (authorityConfigured) { observationsEndpoint.RequireAuthorization(ObservationsPolicyName); } var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async ( HttpContext context, AdvisoryIngestRequest request, [FromServices] IAdvisoryRawService rawService, [FromServices] TimeProvider timeProvider, [FromServices] ILogger logger, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var ingestRequest = request; if (ingestRequest is null || ingestRequest.Source is null || ingestRequest.Upstream is null || ingestRequest.Content is null || ingestRequest.Identifiers is null) { return Problem(context, "Invalid request", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "source, upstream, content, and identifiers sections are required."); } if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } using var ingestScope = logger.BeginScope(new Dictionary(StringComparer.Ordinal) { ["tenant"] = tenant, ["source.vendor"] = ingestRequest.Source.Vendor, ["upstream.upstreamId"] = ingestRequest.Upstream.UpstreamId, ["contentHash"] = ingestRequest.Upstream.ContentHash ?? "(null)" }); AdvisoryRawDocument document; try { logger.LogWarning( "Binding advisory ingest request hash={Hash}", ingestRequest.Upstream.ContentHash ?? "(null)"); document = AdvisoryRawRequestMapper.Map(ingestRequest, tenant, timeProvider); logger.LogWarning( "Mapped advisory_raw document hash={Hash}", string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { return Problem(context, "Invalid advisory payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); } try { var result = await rawService.IngestAsync(document, cancellationToken).ConfigureAwait(false); var response = new AdvisoryIngestResponse( result.Record.Id, result.Inserted, result.Record.Document.Tenant, result.Record.Document.Upstream.ContentHash, result.Record.Document.Supersedes, result.Record.IngestedAt, result.Record.CreatedAt); var statusCode = result.Inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK; if (result.Inserted) { context.Response.Headers.Location = $"/advisories/raw/{Uri.EscapeDataString(result.Record.Id)}"; } IngestionMetrics.IngestionWriteCounter.Add( 1, IngestionMetrics.BuildWriteTags( tenant, ingestRequest.Source.Vendor ?? "(unknown)", result.Inserted ? "inserted" : "duplicate")); return JsonResult(response, statusCode); } catch (ConcelierAocGuardException guardException) { logger.LogWarning( guardException, "AOC guard rejected advisory ingest tenant={Tenant} upstream={UpstreamId} requestHash={RequestHash} documentHash={DocumentHash} codes={Codes}", tenant, document.Upstream.UpstreamId, request!.Upstream?.ContentHash ?? "(null)", string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash, string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode))); IngestionMetrics.IngestionWriteCounter.Add( 1, IngestionMetrics.BuildWriteTags( tenant, ingestRequest.Source.Vendor ?? "(unknown)", "rejected")); return MapAocGuardException(context, guardException); } }); var advisoryIngestGuardOptions = AocGuardOptions.Default with { RequireTenant = false, RequiredTopLevelFields = AocGuardOptions.Default.RequiredTopLevelFields.Remove("tenant") }; advisoryIngestEndpoint.RequireAocGuard(request => { if (request?.Source is null || request.Upstream is null || request.Content is null || request.Identifiers is null) { return Array.Empty(); } var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System); return new object?[] { guardDocument }; }, guardOptions: advisoryIngestGuardOptions); if (authorityConfigured) { advisoryIngestEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); } var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async ( HttpContext context, [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var query = context.Request.Query; var options = new AdvisoryRawQueryOptions(tenant); if (query.TryGetValue("vendor", out var vendorValues)) { options = options with { Vendors = AdvisoryRawRequestMapper.NormalizeStrings(vendorValues) }; } if (query.TryGetValue("upstreamId", out var upstreamValues)) { options = options with { UpstreamIds = AdvisoryRawRequestMapper.NormalizeStrings(upstreamValues) }; } if (query.TryGetValue("alias", out var aliasValues)) { options = options with { Aliases = AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) }; } if (query.TryGetValue("purl", out var purlValues)) { options = options with { PackageUrls = AdvisoryRawRequestMapper.NormalizeStrings(purlValues) }; } if (query.TryGetValue("hash", out var hashValues)) { options = options with { ContentHashes = AdvisoryRawRequestMapper.NormalizeStrings(hashValues) }; } if (query.TryGetValue("since", out var sinceValues)) { var since = ParseDateTime(sinceValues.FirstOrDefault()); if (since.HasValue) { options = options with { Since = since }; } } if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit)) { options = options with { Limit = parsedLimit }; } if (query.TryGetValue("cursor", out var cursorValues)) { var cursor = cursorValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(cursor)) { options = options with { Cursor = cursor }; } } var result = await rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false); var records = result.Records .Select(record => new AdvisoryRawRecordResponse( record.Id, record.Document.Tenant, record.IngestedAt, record.CreatedAt, record.Document)) .ToArray(); var response = new AdvisoryRawListResponse(records, result.NextCursor, result.HasMore); return JsonResult(response); }); if (authorityConfigured) { advisoryRawListEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async ( string id, HttpContext context, [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } if (string.IsNullOrWhiteSpace(id)) { return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); if (record is null) { return ConcelierProblemResultFactory.AdvisoryNotFound(context, id); } var response = new AdvisoryRawRecordResponse( record.Id, record.Document.Tenant, record.IngestedAt, record.CreatedAt, record.Document); return JsonResult(response); }); if (authorityConfigured) { advisoryRawGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async ( string id, HttpContext context, [FromServices] IAdvisoryRawService rawService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } if (string.IsNullOrWhiteSpace(id)) { return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false); if (record is null) { return ConcelierProblemResultFactory.AdvisoryNotFound(context, id); } var response = new AdvisoryRawProvenanceResponse( record.Id, record.Document.Tenant, record.Document.Source, record.Document.Upstream, record.Document.Supersedes, record.IngestedAt, record.CreatedAt); return JsonResult(response); }); if (authorityConfigured) { advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Advisory observations endpoint - filtered by alias/purl/source with strict tenant scopes. // Echoes upstream values + provenance fields only (no merge-derived judgments). var advisoryObservationsEndpoint = app.MapGet("/advisories/observations", async ( HttpContext context, [FromServices] IAdvisoryObservationQueryService observationService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var query = context.Request.Query; // Parse query parameters var aliases = query.TryGetValue("alias", out var aliasValues) ? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) : null; var purls = query.TryGetValue("purl", out var purlValues) ? AdvisoryRawRequestMapper.NormalizeStrings(purlValues) : null; var cpes = query.TryGetValue("cpe", out var cpeValues) ? AdvisoryRawRequestMapper.NormalizeStrings(cpeValues) : null; var observationIds = query.TryGetValue("id", out var idValues) ? AdvisoryRawRequestMapper.NormalizeStrings(idValues) : null; int? limit = null; if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) && parsedLimit > 0) { limit = Math.Min(parsedLimit, 200); // Cap at 200 } string? cursor = null; if (query.TryGetValue("cursor", out var cursorValues)) { var cursorValue = cursorValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(cursorValue)) { cursor = cursorValue.Trim(); } } // Build query options with tenant scope var options = new AdvisoryObservationQueryOptions( tenant, observationIds: observationIds, aliases: aliases, purls: purls, cpes: cpes, limit: limit, cursor: cursor); var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false); // Map to response contracts var linksetResponse = new AdvisoryObservationLinksetAggregateResponse( result.Linkset.Aliases, result.Linkset.Purls, result.Linkset.Cpes, result.Linkset.References, result.Linkset.Scopes, result.Linkset.Relationships, result.Linkset.Confidence, result.Linkset.Conflicts); var response = new AdvisoryObservationQueryResponse( result.Observations, linksetResponse, result.NextCursor, result.HasMore); return JsonResult(response); }).WithName("GetAdvisoryObservations"); if (authorityConfigured) { advisoryObservationsEndpoint.RequireAuthorization(ObservationsPolicyName); } // Advisory linksets endpoint - surfaces correlation + conflict payloads with ERR_AGG_* mapping. // No synthesis/merge - echoes upstream values only. var advisoryLinksetsEndpoint = app.MapGet("/advisories/linksets", async ( HttpContext context, [FromServices] IAdvisoryLinksetQueryService linksetService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var query = context.Request.Query; // Parse advisory IDs (alias values like CVE-*, GHSA-*) var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues) ? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues) : (query.TryGetValue("alias", out var aliasValues) ? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) : null); var sources = query.TryGetValue("source", out var sourceValues) ? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues) : null; int? limit = null; if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) && parsedLimit > 0) { limit = Math.Min(parsedLimit, 500); // Cap at 500 } string? cursor = null; if (query.TryGetValue("cursor", out var cursorValues)) { var cursorValue = cursorValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(cursorValue)) { cursor = cursorValue.Trim(); } } var options = new AdvisoryLinksetQueryOptions( tenant, advisoryIds, sources, limit, cursor); var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false); // Map to LNM linkset response format var items = result.Linksets.Select(linkset => new LnmLinksetResponse( linkset.AdvisoryId, linkset.Source, linkset.Normalized?.Purls ?? Array.Empty(), linkset.Normalized?.Cpes ?? Array.Empty(), null, // Summary not available in linkset null, // PublishedAt null, // ModifiedAt null, // Severity - no derived judgment null, // Status linkset.Provenance is not null ? new LnmLinksetProvenance( linkset.CreatedAt, null, // ConnectorId linkset.Provenance.ObservationHashes?.FirstOrDefault(), null) // DsseEnvelopeHash : null, linkset.Conflicts?.Select(c => new LnmLinksetConflict( c.Field, c.Reason, c.Values?.FirstOrDefault(), null, null)).ToArray() ?? Array.Empty(), Array.Empty(), linkset.Normalized is not null ? new LnmLinksetNormalized( null, // Aliases not in normalized linkset.Normalized.Purls, linkset.Normalized.Cpes, linkset.Normalized.Versions, null) // Ranges serialized differently : null, false, // Not from cache Array.Empty(), linkset.ObservationIds.ToArray())).ToArray(); var response = new LnmLinksetPage(items, 1, items.Length, null); return JsonResult(response); }).WithName("GetAdvisoryLinksets"); if (authorityConfigured) { advisoryLinksetsEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Advisory linksets export endpoint for evidence bundles var advisoryLinksetsExportEndpoint = app.MapGet("/advisories/linksets/export", async ( HttpContext context, [FromServices] IAdvisoryLinksetQueryService linksetService, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var query = context.Request.Query; var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues) ? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues) : null; var sources = query.TryGetValue("source", out var sourceValues) ? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues) : null; var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, 1000, null); var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false); // Export format with provenance metadata var exportItems = result.Linksets.Select(linkset => new { advisoryId = linkset.AdvisoryId, source = linkset.Source, tenantId = linkset.TenantId, observationIds = linkset.ObservationIds.ToArray(), confidence = linkset.Confidence, conflicts = linkset.Conflicts?.Select(c => new { field = c.Field, reason = c.Reason, values = c.Values, sourceIds = c.SourceIds }).ToArray(), normalized = linkset.Normalized is not null ? new { purls = linkset.Normalized.Purls, cpes = linkset.Normalized.Cpes, versions = linkset.Normalized.Versions } : null, provenance = linkset.Provenance is not null ? new { observationHashes = linkset.Provenance.ObservationHashes, toolVersion = linkset.Provenance.ToolVersion, policyHash = linkset.Provenance.PolicyHash } : null, createdAt = linkset.CreatedAt, builtByJobId = linkset.BuiltByJobId }).ToArray(); var export = new { tenant = tenant, exportedAt = timeProvider.GetUtcNow(), count = exportItems.Length, hasMore = result.HasMore, linksets = exportItems }; return JsonResult(export); }).WithName("ExportAdvisoryLinksets"); if (authorityConfigured) { advisoryLinksetsExportEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Internal endpoint for publishing observation events to NATS/Redis. // Publishes advisory.observation.updated@1 events with tenant + provenance references only. app.MapPost("/internal/events/observations/publish", async ( HttpContext context, [FromBody] ObservationEventPublishRequest request, [FromServices] IAdvisoryObservationQueryService observationService, [FromServices] IAdvisoryObservationEventPublisher? eventPublisher, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (eventPublisher is null) { return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available."); } if (request?.ObservationIds is null || request.ObservationIds.Count == 0) { return Problem(context, "observationIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one observation ID."); } var options = new AdvisoryObservationQueryOptions(tenant, observationIds: request.ObservationIds); var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false); var published = 0; foreach (var observation in result.Observations) { var @event = AdvisoryObservationUpdatedEvent.FromObservation( observation, supersedesId: null, traceId: context.TraceIdentifier); await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false); published++; } return Results.Ok(new { tenant, published, requestedCount = request.ObservationIds.Count, timestamp = timeProvider.GetUtcNow() }); }).WithName("PublishObservationEvents"); // Internal endpoint for publishing linkset events to NATS/Redis. // Publishes advisory.linkset.updated@1 events with idempotent keys and tenant + provenance references. app.MapPost("/internal/events/linksets/publish", async ( HttpContext context, [FromBody] LinksetEventPublishRequest request, [FromServices] IAdvisoryLinksetQueryService linksetService, [FromServices] IAdvisoryLinksetEventPublisher? eventPublisher, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (eventPublisher is null) { return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available."); } if (request?.AdvisoryIds is null || request.AdvisoryIds.Count == 0) { return Problem(context, "advisoryIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one advisory ID."); } var options = new AdvisoryLinksetQueryOptions(tenant, request.AdvisoryIds, null, 500); var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false); var published = 0; foreach (var linkset in result.Linksets) { var linksetId = $"{linkset.TenantId}:{linkset.Source}:{linkset.AdvisoryId}"; var @event = AdvisoryLinksetUpdatedEvent.FromLinkset( linkset, previousLinkset: null, linksetId: linksetId, traceId: context.TraceIdentifier); await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false); published++; } return Results.Ok(new { tenant, published, requestedCount = request.AdvisoryIds.Count, hasMore = result.HasMore, timestamp = timeProvider.GetUtcNow() }); }).WithName("PublishLinksetEvents"); var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, [FromServices] IAdvisoryRawService rawService, [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, [FromServices] ILogger logger, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } if (string.IsNullOrWhiteSpace(advisoryKey)) { return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var normalizedKey = advisoryKey.Trim(); var canonicalKey = normalizedKey.ToUpperInvariant(); var vendorFilter = AdvisoryRawRequestMapper.NormalizeStrings(context.Request.Query["vendor"]); var records = await rawService.FindByAdvisoryKeyAsync( tenant, canonicalKey, vendorFilter, cancellationToken).ConfigureAwait(false); if (records.Count == 0) { return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence available for {normalizedKey}."); } var recordResponses = records .Select(record => new AdvisoryRawRecordResponse( record.Id, record.Document.Tenant, record.IngestedAt, record.CreatedAt, record.Document)) .ToArray(); var evidenceOptions = resolvedConcelierOptions.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); var attestation = await TryBuildAttestationAsync( context, evidenceOptions, attestationBuilder, logger, cancellationToken).ConfigureAwait(false); var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey; var response = new AdvisoryEvidenceResponse(responseKey, recordResponses, attestation); return JsonResult(response); }); if (authorityConfigured) { advisoryEvidenceEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var attestationVerifyEndpoint = app.MapPost("/internal/attestations/verify", async ( VerifyAttestationRequest request, HttpContext context, [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, [FromServices] IOptions concelierOptions, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (request is null) { return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide bundle/manifest paths."); } var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); var resolved = ResolveEvidencePaths(request, evidenceOptions.RootAbsolute, evidenceOptions); if (!resolved.IsValid) { return Problem(context, resolved.Error!, StatusCodes.Status400BadRequest, ProblemTypes.Validation, resolved.ErrorDetails ?? string.Empty); } try { var claims = await attestationBuilder.BuildAsync( new EvidenceBundleAttestationRequest( resolved.BundlePath!, resolved.ManifestPath!, resolved.TransparencyPath, request.PipelineVersion ?? evidenceOptions.PipelineVersion ?? "git:unknown"), cancellationToken).ConfigureAwait(false); return Results.Json(claims); } catch (Exception ex) { return Problem(context, "Attestation verification failed", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); } }); if (authorityConfigured) { attestationVerifyEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Evidence snapshot (manifest-only) endpoint for Console/VEX consumers var evidenceSnapshotEndpoint = app.MapGet("/obs/evidence/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, [FromServices] IOptions concelierOptions, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(advisoryKey)) { return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim()); var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json"); var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json"); if (!File.Exists(manifestPath)) { return Problem(context, "Manifest not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No manifest for {advisoryKey} in tenant {tenant}."); } await using var manifestStream = File.OpenRead(manifestPath); var hash = await ComputeSha256Async(manifestStream, cancellationToken).ConfigureAwait(false); var response = new EvidenceSnapshotResponse( AdvisoryKey: advisoryKey.Trim(), Tenant: tenant, ManifestPath: manifestPath, ManifestHash: hash, TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null, PipelineVersion: options.PipelineVersion); return Results.Json(response); }); if (authorityConfigured) { evidenceSnapshotEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Attestation status endpoint (evidence locker proxy) var evidenceAttestationEndpoint = app.MapGet("/obs/attestations/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, [FromServices] IOptions concelierOptions, [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (string.IsNullOrWhiteSpace(advisoryKey)) { return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim()); if (!Directory.Exists(baseDir)) { return Problem(context, "Evidence directory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence for {advisoryKey} in tenant {tenant}."); } var bundlePath = Directory.EnumerateFiles(baseDir, "*.tar*", SearchOption.TopDirectoryOnly).FirstOrDefault(); if (bundlePath is null) { return Problem(context, "Bundle missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No bundle archive found in evidence directory."); } var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json"); var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json"); if (!File.Exists(manifestPath)) { return Problem(context, "Manifest missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "Manifest required to build attestation claims."); } var claims = await attestationBuilder.BuildAsync( new EvidenceBundleAttestationRequest( bundlePath, manifestPath, File.Exists(transparencyPath) ? transparencyPath : null, options.PipelineVersion ?? "git:unknown"), cancellationToken).ConfigureAwait(false); var response = new AttestationStatusResponse( AdvisoryKey: advisoryKey.Trim(), Tenant: tenant, Claims: claims, BundlePath: bundlePath, ManifestPath: manifestPath, TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null, PipelineVersion: options.PipelineVersion); return Results.Json(response); }); if (authorityConfigured) { evidenceAttestationEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } // Incident-mode (ingest pause) endpoints var incidentGetEndpoint = app.MapGet("/obs/incidents/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, [FromServices] IOptions concelierOptions, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); if (status is null) { return Problem(context, "Incident not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No incident marker present."); } return Results.Json(status); }); if (authorityConfigured) { incidentGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var incidentUpsertEndpoint = app.MapPost("/obs/incidents/advisories/{advisoryKey}", async ( string advisoryKey, IncidentUpsertRequest request, HttpContext context, [FromServices] IOptions concelierOptions, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (request is null) { return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide reason/cooldownMinutes."); } var cooldownMinutes = request.CooldownMinutes is null or <= 0 ? 60 : request.CooldownMinutes.Value; var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); await IncidentFileStore.WriteAsync( evidenceOptions, tenant!, advisoryKey, request.Reason ?? "unspecified", cooldownMinutes, evidenceOptions.PipelineVersion, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false); return Results.Json(status); }); if (authorityConfigured) { incidentUpsertEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var incidentDeleteEndpoint = app.MapDelete("/obs/incidents/advisories/{advisoryKey}", async ( string advisoryKey, HttpContext context, [FromServices] IOptions concelierOptions, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions(); await IncidentFileStore.DeleteAsync(evidenceOptions, tenant!, advisoryKey, cancellationToken).ConfigureAwait(false); return Results.NoContent(); }); if (authorityConfigured) { incidentDeleteEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", async ( string advisoryKey, HttpContext context, [FromServices] IAdvisoryObservationQueryService observationService, [FromServices] AdvisoryChunkBuilder chunkBuilder, [FromServices] IAdvisoryChunkCache chunkCache, [FromServices] IAdvisoryStore advisoryStore, [FromServices] IAliasStore aliasStore, [FromServices] IAdvisoryAiTelemetry telemetry, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var requestStart = timeProvider.GetTimestamp(); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { telemetry.TrackChunkFailure(null, advisoryKey ?? string.Empty, "tenant_unresolved", "validation_error"); return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { var failureResult = authorizationError switch { UnauthorizedHttpResult => "unauthorized", _ => "forbidden" }; telemetry.TrackChunkFailure(tenant, advisoryKey ?? string.Empty, "tenant_not_authorized", failureResult); return authorizationError; } if (string.IsNullOrWhiteSpace(advisoryKey)) { telemetry.TrackChunkFailure(tenant, string.Empty, "missing_key", "validation_error"); return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier."); } var normalizedKey = advisoryKey.Trim(); var chunkSettings = resolvedConcelierOptions.AdvisoryChunks ?? new ConcelierOptions.AdvisoryChunkOptions(); var chunkLimit = ResolveBoundedInt(context.Request.Query["limit"], chunkSettings.DefaultChunkLimit, 1, chunkSettings.MaxChunkLimit); var observationLimit = ResolveBoundedInt(context.Request.Query["observations"], chunkSettings.DefaultObservationLimit, 1, chunkSettings.MaxObservationLimit); var minimumLength = ResolveBoundedInt(context.Request.Query["minLength"], chunkSettings.DefaultMinimumLength, 16, chunkSettings.MaxMinimumLength); var sectionFilter = BuildFilterSet(context.Request.Query["section"]); var formatFilter = BuildFilterSet(context.Request.Query["format"]); var resolution = await ResolveAdvisoryAsync( tenant, normalizedKey, advisoryStore, aliasStore, cancellationToken).ConfigureAwait(false); if (resolution is null) { telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found"); return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No advisory found for {normalizedKey}."); } var (advisory, aliasList, fingerprint) = resolution.Value; var aliasCandidates = aliasList.IsDefaultOrEmpty ? ImmutableArray.Create(advisory.AdvisoryKey) : aliasList; var queryOptions = new AdvisoryObservationQueryOptions( tenant, aliases: aliasCandidates, limit: observationLimit); var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false); if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0) { telemetry.TrackChunkFailure(tenant, advisory.AdvisoryKey, "advisory_not_found", "not_found"); return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {advisory.AdvisoryKey}."); } var observations = observationResult.Observations.ToArray(); var buildOptions = new AdvisoryChunkBuildOptions( advisory.AdvisoryKey, fingerprint, chunkLimit, observationLimit, sectionFilter, formatFilter, minimumLength); var cacheDuration = chunkSettings.CacheDurationSeconds > 0 ? TimeSpan.FromSeconds(chunkSettings.CacheDurationSeconds) : TimeSpan.Zero; AdvisoryChunkBuildResult buildResult; var cacheHit = false; string? cacheKeyValue = null; if (cacheDuration > TimeSpan.Zero) { var cacheKey = AdvisoryChunkCacheKey.Create(tenant, advisory.AdvisoryKey, buildOptions, observations, fingerprint); cacheKeyValue = cacheKey.Value; if (chunkCache.TryGet(cacheKey, out var cachedResult)) { buildResult = cachedResult; cacheHit = true; } else { buildResult = chunkBuilder.Build(buildOptions, advisory, observations); chunkCache.Set(cacheKey, buildResult, cacheDuration); } } else { buildResult = chunkBuilder.Build(buildOptions, advisory, observations); } // Expose cache transparency for console/clients (deterministic keys + hit/ttl) var chunkCacheKeyHash = cacheKeyValue is null ? "" : ShortHash(cacheKeyValue); context.Response.Headers["X-Stella-Cache-Key"] = chunkCacheKeyHash; context.Response.Headers["X-Stella-Cache-Hit"] = cacheHit ? "1" : "0"; context.Response.Headers["X-Stella-Cache-Ttl"] = cacheDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture); var duration = timeProvider.GetElapsedTime(requestStart); var guardrailCounts = buildResult.Telemetry.GuardrailCounts ?? ImmutableDictionary.Empty; telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry( tenant, advisory.AdvisoryKey, "ok", buildResult.Response.Truncated, cacheHit, observations.Length, buildResult.Telemetry.SourceCount, buildResult.Response.Entries.Count, duration, guardrailCounts)); return JsonResult(buildResult.Response); }); if (authorityConfigured) { advisoryChunksEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var advisorySummaryEndpoint = app.MapGet("/advisories/summary", async ( HttpContext context, [FromQuery(Name = "purl")] string[]? purls, [FromQuery(Name = "alias")] string[]? aliases, [FromQuery(Name = "source")] string[]? sources, [FromQuery(Name = "confidence_gte")] double? confidenceGte, [FromQuery(Name = "conflicts_only")] bool? conflictsOnly, [FromQuery(Name = "take")] int? take, [FromQuery(Name = "after")] string? after, [FromQuery(Name = "sort")] string? sort, [FromServices] IAdvisoryLinksetQueryService queryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var normalizedTenant = tenant!.ToLowerInvariant(); var limit = take is null or <= 0 ? 100 : Math.Min(take.Value, 500); var sortKey = string.IsNullOrWhiteSpace(sort) ? "advisory" : sort.Trim().ToLowerInvariant(); var advisoryIds = aliases?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToArray(); var sourceFilters = sources?.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToArray(); AdvisoryLinksetQueryResult queryResult; try { queryResult = await queryService.QueryAsync( new AdvisoryLinksetQueryOptions(normalizedTenant, advisoryIds, sourceFilters, Limit: limit, Cursor: after), cancellationToken).ConfigureAwait(false); } catch (FormatException ex) { return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message); } var items = queryResult.Linksets .Where(ls => purls is null || purls.Length == 0 || (ls.Normalized?.Purls?.Any(p => purls.Contains(p, StringComparer.OrdinalIgnoreCase)) ?? false)) .Where(ls => !confidenceGte.HasValue || (ls.Confidence ?? 0) >= confidenceGte.Value) .Where(ls => !conflictsOnly.GetValueOrDefault(false) || (ls.Conflicts?.Count > 0)) .Select(AdvisorySummaryMapper.ToSummary) .ToArray(); IReadOnlyList orderedItems; string? nextCursor; if (sortKey == "advisory") { orderedItems = items .OrderBy(i => i.AdvisoryKey, StringComparer.Ordinal) .ThenBy(i => i.ObservedAt, StringComparer.Ordinal) .Take(limit) .ToArray(); nextCursor = null; // advisory sort pagination not supported yet } else { orderedItems = items .OrderByDescending(i => i.ObservedAt, StringComparer.Ordinal) .ThenBy(i => i.AdvisoryKey, StringComparer.Ordinal) .Take(limit) .ToArray(); nextCursor = queryResult.NextCursor; } var cacheKeyString = BuildSummaryCacheKey(normalizedTenant, purls, aliases, sources, confidenceGte, conflictsOnly, sortKey, limit, after); var cacheHash = ShortHash(cacheKeyString); context.Response.Headers["X-Stella-Cache-Key"] = cacheHash; context.Response.Headers["X-Stella-Cache-Hit"] = "0"; context.Response.Headers["X-Stella-Cache-Ttl"] = "0"; var response = AdvisorySummaryMapper.ToResponse(normalizedTenant, orderedItems, nextCursor, sortKey); return Results.Ok(response); }).WithName("GetAdvisoriesSummary"); // Evidence batch (component-centric) endpoint for graph overlays / evidence exports. app.MapPost("/v1/evidence/batch", async ( HttpContext context, [FromBody] EvidenceBatchRequest request, [FromServices] IAdvisoryObservationQueryService observationService, [FromServices] IAdvisoryLinksetQueryService linksetService, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } if (request?.Items is null || request.Items.Count == 0) { return Problem(context, "At least one batch item is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide items with aliases/purls."); } var resolvedObservationLimit = request.ObservationLimit is > 0 and <= 200 ? request.ObservationLimit.Value : 50; var resolvedLinksetLimit = request.LinksetLimit is > 0 and <= 200 ? request.LinksetLimit.Value : 50; var responses = new List(request.Items.Count); foreach (var item in request.Items) { var componentId = string.IsNullOrWhiteSpace(item.ComponentId) ? "(unnamed)" : item.ComponentId.Trim(); var aliases = item.Aliases?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToArray(); var purls = item.Purls?.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToArray(); AdvisoryObservationQueryResult observationResult = new( ImmutableArray.Empty, new AdvisoryObservationLinksetAggregate( ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), NextCursor: null, HasMore: false); AdvisoryLinksetQueryResult linksetResult = new( ImmutableArray.Empty, NextCursor: null, HasMore: false); if ((aliases?.Length ?? 0) > 0 || (purls?.Length ?? 0) > 0) { var obsOptions = new AdvisoryObservationQueryOptions(tenant, aliases: aliases, purls: purls, limit: resolvedObservationLimit); observationResult = await observationService.QueryAsync(obsOptions, cancellationToken).ConfigureAwait(false); var linksetOptions = new AdvisoryLinksetQueryOptions(tenant, aliases, null, resolvedLinksetLimit); linksetResult = await linksetService.QueryAsync(linksetOptions, cancellationToken).ConfigureAwait(false); } var responseItem = new EvidenceBatchItemResponse( componentId, observationResult.Observations, linksetResult.Linksets, observationResult.HasMore || linksetResult.HasMore, timeProvider.GetUtcNow()); responses.Add(responseItem); } return Results.Ok(new EvidenceBatchResponse(responses)); }).WithName("GetEvidenceBatch"); if (authorityConfigured) { advisorySummaryEndpoint.RequireAuthorization(AdvisoryReadPolicyName); } var aocVerifyEndpoint = app.MapPost("/aoc/verify", async ( HttpContext context, AocVerifyRequest request, [FromServices] IAdvisoryRawService rawService, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } var now = timeProvider.GetUtcNow(); var windowStart = (request?.Since ?? now.AddHours(-24)).ToUniversalTime(); var windowEnd = (request?.Until ?? now).ToUniversalTime(); if (windowEnd < windowStart) { return Problem(context, "Invalid verification window", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "'until' must be greater than 'since'."); } var limit = request?.Limit ?? 20; if (limit < 0) { limit = 0; } var sources = AdvisoryRawRequestMapper.NormalizeStrings(request?.Sources); var codes = AdvisoryRawRequestMapper.NormalizeStrings(request?.Codes); var verificationRequest = new AdvisoryRawVerificationRequest( tenant, windowStart, windowEnd, limit, sources, codes); var result = await rawService.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false); var violationResponses = result.Violations .Select(violation => new AocVerifyViolation( violation.Code, violation.Count, violation.Examples.Select(example => new AocVerifyViolationExample( example.SourceVendor, example.DocumentId, example.ContentHash, example.Path)).ToArray())) .ToArray(); var metrics = new AocVerifyMetrics(result.CheckedCount, result.Violations.Sum(v => v.Count)); var response = new AocVerifyResponse( result.Tenant, new AocVerifyWindow(result.WindowStart, result.WindowEnd), new AocVerifyChecked(result.CheckedCount, 0), violationResponses, metrics, result.Truncated); var verificationOutcome = response.Truncated ? "truncated" : (violationResponses.Length == 0 ? "ok" : "violations"); IngestionMetrics.VerificationCounter.Add( 1, IngestionMetrics.BuildVerifyTags(tenant, verificationOutcome)); return JsonResult(response); }); if (authorityConfigured) { aocVerifyEndpoint.RequireAuthorization(AocVerifyPolicyName); } app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async ( string vulnerabilityKey, DateTimeOffset? asOf, [FromServices] IAdvisoryEventLog eventLog, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(vulnerabilityKey)) { return ConcelierProblemResultFactory.VulnerabilityKeyRequired(context); } var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false); if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0) { return ConcelierProblemResultFactory.VulnerabilityNotFound(context, vulnerabilityKey); } 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, Explainer = MergeConflictExplainerPayload.FromCanonicalJson(conflict.CanonicalJson) }).ToArray() }; return JsonResult(response); }); var statementProvenanceEndpoint = app.MapPost("/events/statements/{statementId:guid}/provenance", async ( Guid statementId, HttpContext context, [FromServices] IAdvisoryEventLog eventLog, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError; } var authorizationError = EnsureTenantAuthorized(context, tenant); if (authorizationError is not null) { return authorizationError; } try { using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false); var (dsse, trust) = ProvenanceJsonParser.Parse(document.RootElement); if (!trust.Verified) { return Problem(context, "Unverified provenance", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "trust.verified must be true."); } await eventLog.AttachStatementProvenanceAsync(statementId, dsse, trust, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { return Problem(context, "Invalid provenance payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message); } catch (InvalidOperationException ex) { return Problem(context, "Statement not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, ex.Message); } return Results.Accepted($"/events/statements/{statementId}"); }); if (authorityConfigured) { statementProvenanceEndpoint.RequireAuthorization(AdvisoryIngestPolicyName); } var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true; if (loggingEnabled) { app.UseSerilogRequestLogging(options => { options.IncludeQueryInRequestPath = true; options.GetLevel = (httpContext, elapsedMs, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error; options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestId", httpContext.TraceIdentifier); diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); if (Activity.Current is { TraceId: var traceId } && traceId != default) { diagnosticContext.Set("TraceId", traceId.ToString()); } }; }); } app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { context.Response.ContentType = "application/problem+json"; var feature = context.Features.Get(); var error = feature?.Error; var extensions = new Dictionary(StringComparer.Ordinal) { ["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier, }; var problem = Results.Problem( detail: error?.Message, instance: context.Request.Path, statusCode: StatusCodes.Status500InternalServerError, title: "Unexpected server error", type: ProblemTypes.JobFailure, extensions: extensions); await problem.ExecuteAsync(context); }); }); if (authorityConfigured) { app.Use(async (context, next) => { await next().ConfigureAwait(false); if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase)) { return; } if (context.Response.StatusCode != StatusCodes.Status401Unauthorized) { return; } var optionsMonitor = context.RequestServices.GetRequiredService>().Value.Authority; if (optionsMonitor is null || !optionsMonitor.Enabled) { return; } var logger = context.RequestServices .GetRequiredService() .CreateLogger(JobAuthorizationAuditFilter.LoggerName); var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks); var remote = context.Connection.RemoteIpAddress; var bypassAllowed = matcher.IsAllowed(remote); logger.LogWarning( "Concelier authorization denied route={Route} remote={RemoteAddress} bypassAllowed={BypassAllowed} hasPrincipal={HasPrincipal}", context.Request.Path.Value ?? string.Empty, remote?.ToString() ?? "unknown", bypassAllowed, context.User?.Identity?.IsAuthenticated ?? false); }); } int NormalizePage(int? pageValue) { if (!pageValue.HasValue || pageValue.Value <= 0) { return 1; } return pageValue.Value; } int NormalizePageSize(int? size) { if (!size.HasValue || size.Value <= 0) { return DefaultLnmPageSize; } return size.Value > MaxLnmPageSize ? MaxLnmPageSize : size.Value; } async Task<(IReadOnlyList Items, int? Total)> QueryPageAsync( IAdvisoryLinksetQueryService queryService, string tenant, IEnumerable? advisoryIds, IEnumerable? sources, int page, int pageSize, CancellationToken cancellationToken) { var cursor = (string?)null; AdvisoryLinksetQueryResult? result = null; for (var current = 1; current <= page; current++) { result = await queryService .QueryAsync(new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, pageSize, cursor), cancellationToken) .ConfigureAwait(false); if (!result.HasMore && current < page) { var exhaustedTotal = ((current - 1) * pageSize) + result.Linksets.Length; return (Array.Empty(), exhaustedTotal); } cursor = result.NextCursor; } if (result is null) { return (Array.Empty(), 0); } var total = result.HasMore ? null : (int?)(((page - 1) * pageSize) + result.Linksets.Length); return (result.Linksets, total); } LnmLinksetResponse ToLnmResponse( AdvisoryLinkset linkset, bool includeConflicts, bool includeTimeline, bool includeObservations, LinksetObservationSummary summary, DataFreshnessInfo? freshness = null, bool cached = false) { var normalized = linkset.Normalized; var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict ? ExtractSeverity(severityDict) : null); var conflicts = includeConflicts ? (linkset.Conflicts ?? Array.Empty()).Select(c => new LnmLinksetConflict( c.Field, c.Reason, c.Values is null ? null : string.Join(", ", c.Values), ObservedAt: null, EvidenceHash: c.SourceIds?.FirstOrDefault())) .ToArray() : Array.Empty(); var timeline = includeTimeline ? BuildTimeline(linkset, summary) : Array.Empty(); var provenance = linkset.Provenance is null ? new LnmLinksetProvenance(linkset.CreatedAt, null, null, null) : new LnmLinksetProvenance( linkset.CreatedAt, null, linkset.Provenance.ObservationHashes?.FirstOrDefault(), null); var normalizedDto = normalized is null ? null : new LnmLinksetNormalized( Aliases: null, Purl: normalized.Purls, Cpe: normalized.Cpes, Versions: normalized.Versions, Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(), Severities: normalized.Severities?.Select(s => (object)s).ToArray()); return new LnmLinksetResponse( linkset.AdvisoryId, linkset.Source, normalized?.Purls ?? Array.Empty(), normalized?.Cpes ?? Array.Empty(), Summary: null, PublishedAt: summary.PublishedAt ?? linkset.CreatedAt, ModifiedAt: summary.ModifiedAt ?? linkset.CreatedAt, Severity: severity, Status: "fact-only", provenance, conflicts, timeline, normalizedDto, Cached: cached, Remarks: Array.Empty(), Observations: includeObservations ? linkset.ObservationIds : Array.Empty(), Freshness: freshness); } string? ExtractSeverity(IReadOnlyDictionary severityDict) { if (severityDict.TryGetValue("system", out var systemObj) && systemObj is string system && !string.IsNullOrWhiteSpace(system) && severityDict.TryGetValue("score", out var scoreObj)) { return $"{system}:{scoreObj}"; } if (severityDict.TryGetValue("score", out var scoreOnly) && scoreOnly is not null) { return scoreOnly.ToString(); } if (severityDict.TryGetValue("value", out var value) && value is string valueString && !string.IsNullOrWhiteSpace(valueString)) { return valueString; } return null; } async Task BuildObservationSummaryAsync( IAdvisoryObservationQueryService observationQueryService, string tenant, AdvisoryLinkset linkset, CancellationToken cancellationToken) { if (linkset.ObservationIds.Length == 0) { return LinksetObservationSummary.Empty; } var options = new AdvisoryObservationQueryOptions( tenant, observationIds: linkset.ObservationIds, limit: linkset.ObservationIds.Length); var result = await observationQueryService.QueryAsync(options, cancellationToken).ConfigureAwait(false); if (result.Observations.IsDefaultOrEmpty) { return LinksetObservationSummary.Empty; } // Observation timelines are not yet populated; return empty summary until ingestion enriches these fields. return LinksetObservationSummary.Empty; } IReadOnlyList BuildTimeline(AdvisoryLinkset linkset, LinksetObservationSummary summary) { var timeline = new List(3) { new("created", linkset.CreatedAt, linkset.Provenance?.ObservationHashes?.FirstOrDefault()), }; if (summary.PublishedAt.HasValue) { timeline.Add(new LnmLinksetTimeline("published", summary.PublishedAt, summary.EvidenceHash)); } if (summary.ModifiedAt.HasValue) { timeline.Add(new LnmLinksetTimeline("modified", summary.ModifiedAt, summary.EvidenceHash)); } return timeline; } IResult JsonResult(T value, int? statusCode = null) { var payload = JsonSerializer.Serialize(value, JsonOptions); return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); } IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary? extensions = null, string? errorCode = null) { var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; extensions ??= new Dictionary(StringComparer.Ordinal) { ["traceId"] = traceId, }; if (!extensions.ContainsKey("traceId")) { extensions["traceId"] = traceId; } // Per CONCELIER-WEB-OAS-61-002: Add error code extension for machine-readable errors if (!string.IsNullOrEmpty(errorCode)) { extensions["error"] = new { code = errorCode, message = detail ?? title }; } var problemDetails = new ProblemDetails { Type = type, Title = title, Detail = detail, Status = statusCode, Instance = context.Request.Path }; foreach (var entry in extensions) { problemDetails.Extensions[entry.Key] = entry.Value; } var payload = JsonSerializer.Serialize(problemDetails, JsonOptions); return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode); } bool TryResolveTenant(HttpContext context, bool requireHeader, out string tenant, out IResult? error) { tenant = string.Empty; error = null; var headerTenant = context.Request.Headers[TenantHeaderName].FirstOrDefault(); var queryTenant = context.Request.Query.TryGetValue("tenant", out var tenantValues) ? tenantValues.FirstOrDefault() : null; if (requireHeader && string.IsNullOrWhiteSpace(headerTenant)) { error = Problem(context, "Tenant header required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Header '{TenantHeaderName}' must be provided."); return false; } if (!string.IsNullOrWhiteSpace(headerTenant) && !string.IsNullOrWhiteSpace(queryTenant) && !string.Equals(headerTenant.Trim(), queryTenant.Trim(), StringComparison.OrdinalIgnoreCase)) { error = Problem(context, "Tenant mismatch", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Values for '{TenantHeaderName}' and 'tenant' query parameter must match."); return false; } var resolved = !string.IsNullOrWhiteSpace(headerTenant) ? headerTenant : queryTenant; if (string.IsNullOrWhiteSpace(resolved)) { error = Problem(context, "Tenant required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Specify the tenant via '{TenantHeaderName}' header or 'tenant' query parameter."); return false; } tenant = resolved.Trim().ToLowerInvariant(); return true; } IResult? EnsureTenantAuthorized(HttpContext context, string tenant) { if (!authorityConfigured) { return null; } if (enforceTenantAllowlist && !requiredTenants.Contains(tenant)) { return Results.Forbid(); } var principal = context.User; if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true)) { return Results.Unauthorized(); } if (principal?.Identity?.IsAuthenticated == true) { var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant); if (string.IsNullOrWhiteSpace(tenantClaim)) { return Results.Forbid(); } var normalizedClaim = tenantClaim.Trim().ToLowerInvariant(); if (!string.Equals(normalizedClaim, tenant, StringComparison.Ordinal)) { return Results.Forbid(); } if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim)) { return Results.Forbid(); } } return null; } async Task<(Advisory Advisory, ImmutableArray Aliases, string Fingerprint)?> ResolveAdvisoryAsync( string tenant, string advisoryKey, IAdvisoryStore advisoryStore, IAliasStore aliasStore, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(tenant)) { return null; } ArgumentNullException.ThrowIfNull(advisoryStore); ArgumentNullException.ThrowIfNull(aliasStore); var directCandidates = new List(); if (!string.IsNullOrWhiteSpace(advisoryKey)) { var trimmed = advisoryKey.Trim(); if (!string.IsNullOrWhiteSpace(trimmed)) { directCandidates.Add(trimmed); var upper = trimmed.ToUpperInvariant(); if (!string.Equals(upper, trimmed, StringComparison.Ordinal)) { directCandidates.Add(upper); } } } foreach (var candidate in directCandidates.Distinct(StringComparer.OrdinalIgnoreCase)) { var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); if (advisory is not null) { return CreateResolution(advisory); } } var aliasMatches = new List(); foreach (var (scheme, value) in BuildAliasLookups(advisoryKey)) { var records = await aliasStore.GetByAliasAsync(scheme, value, cancellationToken).ConfigureAwait(false); if (records.Count > 0) { aliasMatches.AddRange(records); } } if (aliasMatches.Count == 0) { return null; } foreach (var candidate in aliasMatches .OrderByDescending(record => record.UpdatedAt) .ThenBy(record => record.AdvisoryKey, StringComparer.Ordinal) .Select(record => record.AdvisoryKey) .Distinct(StringComparer.OrdinalIgnoreCase)) { var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false); if (advisory is not null) { return CreateResolution(advisory); } } return null; } static (Advisory Advisory, ImmutableArray Aliases, string Fingerprint) CreateResolution(Advisory advisory) { var fingerprint = AdvisoryFingerprint.Compute(advisory); var aliases = BuildAliasQuery(advisory); return (advisory, aliases, fingerprint); } static ImmutableArray BuildAliasQuery(Advisory advisory) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey)) { set.Add(advisory.AdvisoryKey.Trim()); } foreach (var alias in advisory.Aliases) { if (!string.IsNullOrWhiteSpace(alias)) { set.Add(alias.Trim()); } } if (set.Count == 0) { return ImmutableArray.Empty; } var ordered = set .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToList(); var canonical = advisory.AdvisoryKey?.Trim(); if (!string.IsNullOrWhiteSpace(canonical)) { ordered.RemoveAll(value => string.Equals(value, canonical, StringComparison.OrdinalIgnoreCase)); ordered.Insert(0, canonical); } return ordered.ToImmutableArray(); } static IReadOnlyList<(string Scheme, string Value)> BuildAliasLookups(string? candidate) { var pairs = new List<(string Scheme, string Value)>(); var seen = new HashSet(StringComparer.Ordinal); void Add(string scheme, string? value) { if (string.IsNullOrWhiteSpace(scheme) || string.IsNullOrWhiteSpace(value)) { return; } var trimmed = value.Trim(); if (trimmed.Length == 0) { return; } var key = $"{scheme}\u0001{trimmed}"; if (seen.Add(key)) { pairs.Add((scheme, trimmed)); } } if (AliasSchemeRegistry.TryNormalize(candidate, out var normalized, out var scheme)) { Add(scheme, normalized); } Add(AliasStoreConstants.UnscopedScheme, candidate); Add(AliasStoreConstants.PrimaryScheme, candidate); return pairs; } ImmutableHashSet BuildFilterSet(StringValues values) { if (values.Count == 0) { return ImmutableHashSet.Empty; } var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) { continue; } var segments = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length == 0) { builder.Add(value.Trim()); continue; } foreach (var segment in segments) { if (!string.IsNullOrWhiteSpace(segment)) { builder.Add(segment.Trim()); } } } return builder.ToImmutable(); } int ResolveBoundedInt(StringValues values, int fallback, int minValue, int maxValue) { foreach (var value in values) { if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return Math.Clamp(parsed, minValue, maxValue); } } return Math.Clamp(fallback, minValue, maxValue); } static string BuildSummaryCacheKey( string tenant, IEnumerable? purls, IEnumerable? aliases, IEnumerable? sources, double? confidenceGte, bool? conflictsOnly, string sort, int take, string? after) { static string Join(IEnumerable? values) => values is null ? string.Empty : string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.ToLowerInvariant()).OrderBy(v => v, StringComparer.Ordinal)); return string.Join("|", tenant, Join(purls), Join(aliases), Join(sources), confidenceGte?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, conflictsOnly.GetValueOrDefault(false) ? "1" : "0", sort, take.ToString(CultureInfo.InvariantCulture), after ?? string.Empty); } static string ShortHash(string input) { using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(bytes, 0, 8).ToLowerInvariant(); } static DateTimeOffset? ParseDateTime(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.ToUniversalTime() : null; } static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) { stream.Seek(0, SeekOrigin.Begin); using var sha = SHA256.Create(); var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exception) { var guardException = new AocGuardException(exception.Result); return AocHttpResults.Problem(context, guardException); } static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome) => new[] { new KeyValuePair("job.kind", jobKind), new KeyValuePair("job.trigger", trigger), new KeyValuePair("job.outcome", outcome), }; static async Task TryBuildAttestationAsync( HttpContext context, ConcelierOptions.EvidenceBundleOptions evidenceOptions, EvidenceBundleAttestationBuilder builder, Microsoft.Extensions.Logging.ILogger logger, CancellationToken cancellationToken) { var bundlePath = context.Request.Query.TryGetValue("bundlePath", out var bundleValues) ? bundleValues.FirstOrDefault() : null; if (string.IsNullOrWhiteSpace(bundlePath)) { return null; } var manifestPath = context.Request.Query.TryGetValue("manifestPath", out var manifestValues) ? manifestValues.FirstOrDefault() : null; var transparencyPath = context.Request.Query.TryGetValue("transparencyPath", out var transparencyValues) ? transparencyValues.FirstOrDefault() : null; var pipelineVersion = context.Request.Query.TryGetValue("pipelineVersion", out var pipelineValues) ? pipelineValues.FirstOrDefault() : null; pipelineVersion = string.IsNullOrWhiteSpace(pipelineVersion) ? evidenceOptions.PipelineVersion : pipelineVersion.Trim(); var root = evidenceOptions.RootAbsolute; var resolvedBundlePath = ResolveEvidencePath(bundlePath, root); if (string.IsNullOrWhiteSpace(resolvedBundlePath) || !File.Exists(resolvedBundlePath)) { return null; } var resolvedManifestPath = string.IsNullOrWhiteSpace(manifestPath) ? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultManifestFileName) : ResolveEvidencePath(manifestPath!, root); if (string.IsNullOrWhiteSpace(resolvedManifestPath) || !File.Exists(resolvedManifestPath)) { return null; } var resolvedTransparencyPath = string.IsNullOrWhiteSpace(transparencyPath) ? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultTransparencyFileName) : ResolveEvidencePath(transparencyPath!, root); try { return await builder.BuildAsync( new EvidenceBundleAttestationRequest( resolvedBundlePath!, resolvedManifestPath!, resolvedTransparencyPath, pipelineVersion ?? "git:unknown"), cancellationToken).ConfigureAwait(false); } catch (Exception ex) { logger.LogWarning(ex, "Failed to build attestation for evidence bundle {BundlePath}", resolvedBundlePath); return null; } } static string? ResolveEvidencePath(string candidate, string root) { if (string.IsNullOrWhiteSpace(candidate)) { return null; } var effectiveRoot = root ?? string.Empty; var path = candidate; if (!Path.IsPathRooted(path) && !string.IsNullOrWhiteSpace(effectiveRoot)) { path = Path.Combine(effectiveRoot, path); } var fullPath = Path.GetFullPath(path); if (!string.IsNullOrWhiteSpace(effectiveRoot)) { var rootPath = Path.GetFullPath(effectiveRoot) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { return null; } } return fullPath; } static EvidencePathResolutionResult ResolveEvidencePaths( VerifyAttestationRequest request, string root, ConcelierOptions.EvidenceBundleOptions evidenceOptions) { var effectiveRoot = string.IsNullOrWhiteSpace(root) ? string.Empty : root; var bundlePath = ResolveEvidencePath(request.BundlePath ?? string.Empty, effectiveRoot); if (string.IsNullOrWhiteSpace(bundlePath) || !File.Exists(bundlePath)) { return EvidencePathResolutionResult.Invalid("Bundle path not found", request.BundlePath); } var manifestPath = string.IsNullOrWhiteSpace(request.ManifestPath) ? ResolveSibling(bundlePath, evidenceOptions.DefaultManifestFileName) : ResolveEvidencePath(request.ManifestPath!, effectiveRoot); if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath)) { return EvidencePathResolutionResult.Invalid("Manifest path not found", request.ManifestPath); } var transparencyPath = string.IsNullOrWhiteSpace(request.TransparencyPath) ? ResolveSibling(bundlePath, evidenceOptions.DefaultTransparencyFileName) : ResolveEvidencePath(request.TransparencyPath!, effectiveRoot); return EvidencePathResolutionResult.Valid(bundlePath!, manifestPath!, transparencyPath); } static string? ResolveSibling(string? bundlePath, string? fileName) { if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(fileName)) { return null; } var directory = Path.GetDirectoryName(bundlePath); if (string.IsNullOrWhiteSpace(directory)) { return null; } return Path.Combine(directory, fileName); } void ApplyNoCache(HttpResponse response) { if (response is null) { return; } response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate"; response.Headers.Pragma = "no-cache"; response.Headers["Expires"] = "0"; } await InitializeMongoAsync(app); app.MapGet("/health", ([FromServices] IOptions opts, [FromServices] StellaOps.Concelier.WebService.Diagnostics.ServiceStatus status, HttpContext context) => { ApplyNoCache(context.Response); var snapshot = status.CreateSnapshot(); var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); var storage = new StorageBootstrapHealth( Driver: opts.Value.Storage.Driver, Completed: snapshot.BootstrapCompletedAt is not null, CompletedAt: snapshot.BootstrapCompletedAt, DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds); var telemetry = new TelemetryHealth( Enabled: opts.Value.Telemetry.Enabled, Tracing: opts.Value.Telemetry.EnableTracing, Metrics: opts.Value.Telemetry.EnableMetrics, Logging: opts.Value.Telemetry.EnableLogging); var response = new HealthDocument( Status: "healthy", StartedAt: snapshot.StartedAt, UptimeSeconds: uptimeSeconds, Storage: storage, Telemetry: telemetry); return JsonResult(response); }); app.MapGet("/ready", async ([FromServices] IMongoDatabase database, [FromServices] StellaOps.Concelier.WebService.Diagnostics.ServiceStatus status, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var stopwatch = Stopwatch.StartNew(); try { await database.RunCommandAsync((Command)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false); stopwatch.Stop(); status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null); var snapshot = status.CreateSnapshot(); var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); var mongo = new MongoReadyHealth( Status: "ready", LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, CheckedAt: snapshot.LastReadyCheckAt, Error: null); var response = new ReadyDocument( Status: "ready", StartedAt: snapshot.StartedAt, UptimeSeconds: uptimeSeconds, Mongo: mongo); return JsonResult(response); } catch (Exception ex) { stopwatch.Stop(); status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); var snapshot = status.CreateSnapshot(); var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d); var mongo = new MongoReadyHealth( Status: "unready", LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds, CheckedAt: snapshot.LastReadyCheckAt, Error: snapshot.LastMongoError ?? ex.Message); var response = new ReadyDocument( Status: "unready", StartedAt: snapshot.StartedAt, UptimeSeconds: uptimeSeconds, Mongo: mongo); var extensions = new Dictionary(StringComparer.Ordinal) { ["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds, ["mongoError"] = snapshot.LastMongoError ?? ex.Message, }; return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions); } }); app.MapGet("/diagnostics/aliases/{seed}", async (string seed, [FromServices] AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); if (string.IsNullOrWhiteSpace(seed)) { return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation); } var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false); var aliases = component.AliasMap.ToDictionary( static kvp => kvp.Key, static kvp => kvp.Value .Select(record => new { record.Scheme, record.Value, UpdatedAt = record.UpdatedAt }) .ToArray()); var response = new { Seed = component.SeedAdvisoryKey, Advisories = component.AdvisoryKeys, Collisions = component.Collisions .Select(collision => new { collision.Scheme, collision.Value, AdvisoryKeys = collision.AdvisoryKeys }) .ToArray(), Aliases = aliases }; return JsonResult(response); }); var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200); var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); return JsonResult(payload); }).AddEndpointFilter(); if (enforceAuthority) { jobsListEndpoint.RequireAuthorization(JobsPolicyName); } var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var run = await coordinator.GetRunAsync(runId, cancellationToken).ConfigureAwait(false); if (run is null) { return Problem(context, "Job run not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job run '{runId}' was not found."); } return JsonResult(JobRunResponse.FromSnapshot(run)); }).AddEndpointFilter(); if (enforceAuthority) { jobByIdEndpoint.RequireAuthorization(JobsPolicyName); } var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); if (definitions.Count == 0) { return JsonResult(Array.Empty()); } var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray(); var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false); var responses = new List(definitions.Count); foreach (var definition in definitions) { lastRuns.TryGetValue(definition.Kind, out var lastRun); responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun)); } return JsonResult(responses); }).AddEndpointFilter(); if (enforceAuthority) { jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); } var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); if (definition is null) { return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); } var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false); lastRuns.TryGetValue(definition.Kind, out var lastRun); var response = JobDefinitionResponse.FromDefinition(definition, lastRun); return JsonResult(response); }).AddEndpointFilter(); if (enforceAuthority) { jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); } var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false)) .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal)); if (definition is null) { return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); } var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200); var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); return JsonResult(payload); }).AddEndpointFilter(); if (enforceAuthority) { jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); } var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); return JsonResult(payload); }).AddEndpointFilter(); if (enforceAuthority) { activeJobsEndpoint.RequireAuthorization(JobsPolicyName); } var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, [FromServices] IJobCoordinator coordinator, HttpContext context) => { ApplyNoCache(context.Response); request ??= new JobTriggerRequest(); request.Parameters ??= new Dictionary(StringComparer.Ordinal); var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger; var lifetime = context.RequestServices.GetRequiredService(); var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false); var outcome = result.Outcome; var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant()); switch (outcome) { case JobTriggerOutcome.Accepted: JobMetrics.TriggerCounter.Add(1, tags); if (result.Run is null) { return Results.StatusCode(StatusCodes.Status202Accepted); } var acceptedRun = JobRunResponse.FromSnapshot(result.Run); context.Response.Headers.Location = $"/jobs/{acceptedRun.RunId}"; return JsonResult(acceptedRun, StatusCodes.Status202Accepted); case JobTriggerOutcome.NotFound: JobMetrics.TriggerConflictCounter.Add(1, tags); return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered."); case JobTriggerOutcome.Disabled: JobMetrics.TriggerConflictCounter.Add(1, tags); return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled."); case JobTriggerOutcome.AlreadyRunning: JobMetrics.TriggerConflictCounter.Add(1, tags); return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run."); case JobTriggerOutcome.LeaseRejected: JobMetrics.TriggerConflictCounter.Add(1, tags); return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease."); case JobTriggerOutcome.InvalidParameters: { JobMetrics.TriggerConflictCounter.Add(1, tags); var extensions = new Dictionary(StringComparer.Ordinal) { ["parameters"] = request.Parameters, }; return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions); } case JobTriggerOutcome.Cancelled: { JobMetrics.TriggerConflictCounter.Add(1, tags); var extensions = new Dictionary(StringComparer.Ordinal) { ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), }; return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions); } case JobTriggerOutcome.Failed: { JobMetrics.TriggerFailureCounter.Add(1, tags); var extensions = new Dictionary(StringComparer.Ordinal) { ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run), }; return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions); } default: JobMetrics.TriggerFailureCounter.Add(1, tags); return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); } }).AddEndpointFilter(); if (enforceAuthority) { triggerJobEndpoint.RequireAuthorization(JobsPolicyName); } var concelierHealthEndpoint = app.MapGet("/obs/concelier/health", ( HttpContext context, TimeProvider timeProvider) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError!; } var now = timeProvider.GetUtcNow(); var payload = new ConcelierHealthResponse( Tenant: tenant, QueueDepth: 0, IngestLatencyP50Ms: 0, IngestLatencyP99Ms: 0, ErrorRate1h: 0.0, SloBurnRate: 0.0, Window: "5m", UpdatedAt: now.ToString("O", CultureInfo.InvariantCulture)); return Results.Ok(payload); }); var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async ( HttpContext context, TimeProvider timeProvider, ILoggerFactory loggerFactory, [FromQuery] string? cursor, [FromQuery] int? limit, CancellationToken cancellationToken) => { if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { return tenantError!; } var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100); var startId = 0; var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId)) { return ConcelierProblemResultFactory.InvalidCursor(context); } var logger = loggerFactory.CreateLogger("ConcelierTimeline"); context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-Accel-Buffering"] = "no"; context.Response.ContentType = "text/event-stream"; // SSE retry hint (5s) to encourage clients to reconnect with cursor await context.Response.WriteAsync("retry: 5000\n\n", cancellationToken).ConfigureAwait(false); var now = timeProvider.GetUtcNow(); var events = Enumerable.Range(startId, take) .Select(id => new ConcelierTimelineEvent( Type: "ingest.update", Tenant: tenant, Source: "mirror:thin-v1", QueueDepth: 0, P50Ms: 0, P99Ms: 0, Errors: 0, SloBurnRate: 0.0, TraceId: null, OccurredAt: now.ToString("O", CultureInfo.InvariantCulture))) .ToList(); foreach (var (evt, idx) in events.Select((e, i) => (e, i))) { cancellationToken.ThrowIfCancellationRequested(); var id = startId + idx; await context.Response.WriteAsync($"id: {id}\n", cancellationToken).ConfigureAwait(false); await context.Response.WriteAsync($"event: {evt.Type}\n", cancellationToken).ConfigureAwait(false); await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken).ConfigureAwait(false); } await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); var nextCursor = startId + events.Count; context.Response.Headers["X-Next-Cursor"] = nextCursor.ToString(CultureInfo.InvariantCulture); logger.LogInformation("obs timeline emitted {Count} events for tenant {Tenant} starting at {StartId} next {Next}", events.Count, tenant, startId, nextCursor); return Results.Empty; }); await app.RunAsync(); } static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); options.Converters.Add(new JsonStringEnumConverter()); return options; } // Linkset summary used by advisory summary timeline private readonly record struct LinksetObservationSummary( DateTimeOffset? PublishedAt, DateTimeOffset? ModifiedAt, string? Severity, string? EvidenceHash) { public static LinksetObservationSummary Empty { get; } = new(null, null, null, null); } static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot) { var pluginOptions = new PluginHostOptions { BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot, PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"), PrimaryPrefix = "StellaOps.Concelier", EnsureDirectoryExists = true, RecursiveSearch = false, }; if (options.Plugins.SearchPatterns.Count == 0) { pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll"); } else { foreach (var pattern in options.Plugins.SearchPatterns) { if (!string.IsNullOrWhiteSpace(pattern)) { pluginOptions.SearchPatterns.Add(pattern); } } } return pluginOptions; } static async Task InitializeMongoAsync(WebApplication app) { // Skip Mongo initialization in testing/bypass mode. var isTesting = string.Equals( Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), "Testing", StringComparison.OrdinalIgnoreCase); var bypass = string.Equals( Environment.GetEnvironmentVariable("CONCELIER_BYPASS_MONGO"), "1", StringComparison.OrdinalIgnoreCase); if (isTesting || bypass) { return; } await using var scope = app.Services.CreateAsyncScope(); var bootstrapper = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("MongoBootstrapper"); var status = scope.ServiceProvider.GetRequiredService(); var stopwatch = Stopwatch.StartNew(); try { await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false); stopwatch.Stop(); status.MarkBootstrapCompleted(stopwatch.Elapsed); logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); } catch (Exception ex) { stopwatch.Stop(); status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message); logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds); throw; } } } }