#pragma warning disable CS0436 // Type conflicts with imported type - local Program class is intentionally used using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; using Serilog; using Serilog.Events; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Configuration; using StellaOps.DependencyInjection; using StellaOps.Findings.Ledger.Domain; using Domain = StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Infrastructure; using StellaOps.Findings.Ledger.Infrastructure.AirGap; using StellaOps.Findings.Ledger.Infrastructure.Merkle; using StellaOps.Findings.Ledger.Infrastructure.Postgres; using StellaOps.Findings.Ledger.Infrastructure.Projection; using StellaOps.Findings.Ledger.Infrastructure.Policy; using StellaOps.Findings.Ledger.Infrastructure.Exports; using StellaOps.Findings.Ledger.Options; using StellaOps.Findings.Ledger.Services; using StellaOps.Findings.Ledger.WebService.Contracts; using StellaOps.Findings.Ledger.WebService.Mappings; using StellaOps.Findings.Ledger.WebService.Services; using StellaOps.Findings.Ledger.WebService.Endpoints; using StellaOps.Telemetry.Core; using StellaOps.Findings.Ledger.Services.Security; using StellaOps.Findings.Ledger; using StellaOps.Findings.Ledger.Observability; using StellaOps.Findings.Ledger.OpenApi; using System.Text.Json.Nodes; using System.Security.Cryptography; using System.Text; using System.Threading.RateLimiting; using StellaOps.Findings.Ledger.Services.Incident; using StellaOps.Router.AspNet; const string LedgerWritePolicy = "ledger.events.write"; const string LedgerExportPolicy = "ledger.export.read"; // Scoring API policies (SPRINT_8200.0012.0004 - Wave 7) const string ScoringReadPolicy = "scoring.read"; const string ScoringWritePolicy = "scoring.write"; const string ScoringAdminPolicy = "scoring.admin"; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddStellaOpsDefaults(options => { options.BasePath = builder.Environment.ContentRootPath; options.EnvironmentPrefix = "FINDINGS_LEDGER_"; options.ConfigureBuilder = configurationBuilder => { configurationBuilder.AddYamlFile("../etc/findings-ledger.yaml", optional: true, reloadOnChange: true); }; }); var bootstrapOptions = builder.Configuration.BindOptions( LedgerServiceOptions.SectionName, (opts, _) => opts.Validate()); LedgerMetrics.ConfigureQuotas(bootstrapOptions.Quotas.MaxIngestBacklog); builder.Host.UseSerilog((context, services, loggerConfiguration) => { loggerConfiguration .MinimumLevel.Information() .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .Enrich.FromLogContext() .WriteTo.Console(); }); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(LedgerServiceOptions.SectionName)) .PostConfigure(options => options.Validate()) .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(LedgerIncidentOptions.SectionName)) .PostConfigure(options => options.Validate()) .ValidateOnStart(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddProblemDetails(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHealthChecks(); builder.Services.AddStellaOpsTelemetry( builder.Configuration, serviceName: "StellaOps.Findings.Ledger", configureMetrics: meterBuilder => { meterBuilder.AddMeter("StellaOps.Findings.Ledger"); }); // Rate limiting is handled by API Gateway - see docs/modules/gateway/rate-limiting.md // Endpoint-level rate limits: scoring-read (1000/min), scoring-calculate (100/min), scoring-batch (10/min), scoring-webhook (10/min) builder.Services.AddIncidentMode(builder.Configuration); builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: null, configure: resourceOptions => { resourceOptions.Authority = bootstrapOptions.Authority.Issuer; resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata; resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress; resourceOptions.BackchannelTimeout = bootstrapOptions.Authority.BackchannelTimeout; resourceOptions.TokenClockSkew = bootstrapOptions.Authority.TokenClockSkew; resourceOptions.Audiences.Clear(); foreach (var audience in bootstrapOptions.Authority.Audiences) { resourceOptions.Audiences.Add(audience); } resourceOptions.RequiredScopes.Clear(); foreach (var scope in bootstrapOptions.Authority.RequiredScopes) { resourceOptions.RequiredScopes.Add(scope); } foreach (var network in bootstrapOptions.Authority.BypassNetworks) { resourceOptions.BypassNetworks.Add(network); } }); builder.Services.AddAuthorization(options => { var scopes = bootstrapOptions.Authority.RequiredScopes.Count > 0 ? bootstrapOptions.Authority.RequiredScopes.ToArray() : new[] { StellaOpsScopes.VulnOperate }; options.AddPolicy(LedgerWritePolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); options.AddPolicy(LedgerExportPolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); // Scoring API policies (SPRINT_8200.0012.0004 - Wave 7) options.AddPolicy(ScoringReadPolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); options.AddPolicy(ScoringWritePolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); options.AddPolicy(ScoringAdminPolicy, policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme); }); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("ledger-policy-engine"); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Finding summary, evidence graph, reachability, and runtime timeline endpoints builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Alert and Decision services (SPRINT_3602) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Evidence-Weighted Score services (SPRINT_8200.0012.0004) builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Webhook services (SPRINT_8200.0012.0004 - Wave 6) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("webhook-delivery", client => { client.Timeout = TimeSpan.FromSeconds(30); }); // Stella Router integration var routerOptions = builder.Configuration.GetSection("FindingsLedger:Router").Get(); builder.Services.TryAddStellaRouter( serviceName: "findings-ledger", version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", routerOptions: routerOptions); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseExceptionHandler(exceptionApp => { exceptionApp.Run(async context => { var feature = context.Features.Get(); if (feature?.Error is null) { return; } var problem = Results.Problem( statusCode: StatusCodes.Status500InternalServerError, title: "ledger_internal_error", detail: feature.Error.Message); await problem.ExecuteAsync(context); }); }); app.UseAuthentication(); app.UseAuthorization(); app.TryUseStellaRouter(routerOptions); app.MapHealthChecks("/healthz"); app.MapPost("/vuln/ledger/events", async Task, Ok, ProblemHttpResult>> ( HttpContext httpContext, IConsoleCsrfValidator csrfValidator, LedgerEventRequest request, ILedgerEventWriteService writeService, CancellationToken cancellationToken) => { csrfValidator.Validate(httpContext); var draft = request.ToDraft(); var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); return result.Status switch { LedgerWriteStatus.Success => CreateCreatedResponse(result.Record!), LedgerWriteStatus.Idempotent => TypedResults.Ok(CreateResponse(result.Record!, "idempotent")), LedgerWriteStatus.ValidationFailed => TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "validation_failed", detail: string.Join(";", result.Errors)), LedgerWriteStatus.Conflict => TypedResults.Problem( statusCode: StatusCodes.Status409Conflict, title: result.ConflictCode ?? "conflict", detail: string.Join(";", result.Errors)), _ => TypedResults.Problem( statusCode: StatusCodes.Status500InternalServerError, title: "ledger_internal_error", detail: "Unexpected ledger status.") }; }) .WithName("LedgerEventAppend") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status500InternalServerError); app.MapGet("/ledger/export/findings", async Task>, ProblemHttpResult>> ( HttpContext httpContext, ExportQueryService exportQueryService, CancellationToken cancellationToken) => { DeprecationHeaders.Apply(httpContext.Response, "ledger.export.findings"); if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant", detail: "X-Stella-Tenant header is required."); } var tenantId = tenantValues.ToString(); var shape = httpContext.Request.Query["shape"].ToString(); if (string.IsNullOrWhiteSpace(shape)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact)."); } var pageSize = exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])); long? sinceSequence = ParseLong(httpContext.Request.Query["since_sequence"]); long? untilSequence = ParseLong(httpContext.Request.Query["until_sequence"]); DateTimeOffset? sinceObservedAt = ParseDate(httpContext.Request.Query["since_observed_at"]); DateTimeOffset? untilObservedAt = ParseDate(httpContext.Request.Query["until_observed_at"]); var status = httpContext.Request.Query["finding_status"].ToString(); var severity = ParseDecimal(httpContext.Request.Query["severity"]); var request = new ExportFindingsRequest( TenantId: tenantId, Shape: shape, SinceSequence: sinceSequence, UntilSequence: untilSequence, SinceObservedAt: sinceObservedAt, UntilObservedAt: untilObservedAt, Status: string.IsNullOrWhiteSpace(status) ? null : status, Severity: severity, PageSize: pageSize, FiltersHash: string.Empty, PagingKey: null); var filtersHash = exportQueryService.ComputeFiltersHash(request); ExportPagingKey? pagingKey = null; var pageToken = httpContext.Request.Query["page_token"].ToString(); if (!string.IsNullOrWhiteSpace(pageToken)) { if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token"); } pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash); } request = request with { FiltersHash = filtersHash, PagingKey = pagingKey }; ExportPage page; try { page = await exportQueryService.GetFindingsAsync(request, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch") { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch"); } return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false); }) .WithName("LedgerExportFindings") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status500InternalServerError); app.MapGet("/v1/ledger/attestations", async Task>, ProblemHttpResult>> ( HttpContext httpContext, AttestationQueryService attestationQueryService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var request = new AttestationQueryRequest( tenantId, httpContext.Request.Query["artifactId"].ToString(), httpContext.Request.Query["findingId"].ToString(), httpContext.Request.Query["attestationId"].ToString(), httpContext.Request.Query["status"].ToString(), ParseDate(httpContext.Request.Query["sinceRecordedAt"]), ParseDate(httpContext.Request.Query["untilRecordedAt"]), attestationQueryService.ClampLimit(ParseInt(httpContext.Request.Query["limit"])), FiltersHash: string.Empty, PagingKey: null); var filtersHash = attestationQueryService.ComputeFiltersHash(request); AttestationPagingKey? pagingKey = null; var pageToken = httpContext.Request.Query["page_token"].ToString(); if (!string.IsNullOrWhiteSpace(pageToken)) { if (!attestationQueryService.TryParsePageToken(pageToken, filtersHash, out pagingKey, out var error)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token"); } } request = request with { FiltersHash = filtersHash, PagingKey = pagingKey }; ExportPage page; try { page = await attestationQueryService.GetAttestationsAsync(request, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch") { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch"); } return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false); }) .WithName("LedgerAttestationsList") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/ledger/export/vex", async Task>, ProblemHttpResult>> ( HttpContext httpContext, ExportQueryService exportQueryService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var shape = httpContext.Request.Query["shape"].ToString(); if (string.IsNullOrWhiteSpace(shape)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact)."); } var request = new ExportVexRequest( tenantId, shape, ParseLong(httpContext.Request.Query["since_sequence"]), ParseLong(httpContext.Request.Query["until_sequence"]), ParseDate(httpContext.Request.Query["since_observed_at"]), ParseDate(httpContext.Request.Query["until_observed_at"]), httpContext.Request.Query["product_id"].ToString(), httpContext.Request.Query["advisory_id"].ToString(), httpContext.Request.Query["status"].ToString(), httpContext.Request.Query["statement_type"].ToString(), exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])), FiltersHash: string.Empty, PagingKey: null); var filtersHash = exportQueryService.ComputeFiltersHash(request); ExportPagingKey? pagingKey = null; var pageToken = httpContext.Request.Query["page_token"].ToString(); if (!string.IsNullOrWhiteSpace(pageToken)) { if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token"); } pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash); } request = request with { FiltersHash = filtersHash, PagingKey = pagingKey }; ExportPage page; try { page = await exportQueryService.GetVexAsync(request, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch") { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch"); } return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false); }) .WithName("LedgerExportVex") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/ledger/export/advisories", async Task>, ProblemHttpResult>> ( HttpContext httpContext, ExportQueryService exportQueryService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var shape = httpContext.Request.Query["shape"].ToString(); if (string.IsNullOrWhiteSpace(shape)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact)."); } var kev = ParseBool(httpContext.Request.Query["kev"]); var cvssScoreMin = ParseDecimal(httpContext.Request.Query["cvss_score_min"]); var cvssScoreMax = ParseDecimal(httpContext.Request.Query["cvss_score_max"]); var request = new ExportAdvisoryRequest( tenantId, shape, ParseLong(httpContext.Request.Query["since_sequence"]), ParseLong(httpContext.Request.Query["until_sequence"]), ParseDate(httpContext.Request.Query["since_observed_at"]), ParseDate(httpContext.Request.Query["until_observed_at"]), httpContext.Request.Query["severity"].ToString(), httpContext.Request.Query["source"].ToString(), httpContext.Request.Query["cwe_id"].ToString(), kev, httpContext.Request.Query["cvss_version"].ToString(), cvssScoreMin, cvssScoreMax, exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])), FiltersHash: string.Empty, PagingKey: null); var filtersHash = exportQueryService.ComputeFiltersHash(request); ExportPagingKey? pagingKey = null; var pageToken = httpContext.Request.Query["page_token"].ToString(); if (!string.IsNullOrWhiteSpace(pageToken)) { if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token"); } pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash); } request = request with { FiltersHash = filtersHash, PagingKey = pagingKey }; ExportPage page; try { page = await exportQueryService.GetAdvisoriesAsync(request, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch") { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch"); } return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false); }) .WithName("LedgerExportAdvisories") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/ledger/export/sboms", async Task>, ProblemHttpResult>> ( HttpContext httpContext, ExportQueryService exportQueryService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var shape = httpContext.Request.Query["shape"].ToString(); if (string.IsNullOrWhiteSpace(shape)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact)."); } var request = new ExportSbomRequest( tenantId, shape, ParseLong(httpContext.Request.Query["since_sequence"]), ParseLong(httpContext.Request.Query["until_sequence"]), ParseDate(httpContext.Request.Query["since_observed_at"]), ParseDate(httpContext.Request.Query["until_observed_at"]), httpContext.Request.Query["subject_digest"].ToString(), httpContext.Request.Query["sbom_format"].ToString(), httpContext.Request.Query["component_purl"].ToString(), ParseBool(httpContext.Request.Query["contains_native"]), httpContext.Request.Query["slsa_build_type"].ToString(), exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])), FiltersHash: string.Empty, PagingKey: null); var filtersHash = exportQueryService.ComputeFiltersHash(request); ExportPagingKey? pagingKey = null; var pageToken = httpContext.Request.Query["page_token"].ToString(); if (!string.IsNullOrWhiteSpace(pageToken)) { if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token"); } pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash); } request = request with { FiltersHash = filtersHash, PagingKey = pagingKey }; ExportPage page; try { page = await exportQueryService.GetSbomsAsync(request, cancellationToken).ConfigureAwait(false); } catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch") { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch"); } return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false); }) .WithName("LedgerExportSboms") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/internal/ledger/orchestrator-export", async Task, ProblemHttpResult>> ( HttpContext httpContext, OrchestratorExportRequest request, OrchestratorExportService service, CancellationToken cancellationToken) => { if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant"); } var tenantId = tenantValues.ToString(); var input = new OrchestratorExportInput( tenantId, request.RunId, request.JobType, request.ArtifactHash, request.PolicyHash, request.StartedAt, request.CompletedAt, request.Status, request.ManifestPath, request.LogsPath); var record = await service.RecordAsync(input, cancellationToken).ConfigureAwait(false); var response = new OrchestratorExportResponse(record.RunId, record.MerkleRoot); return TypedResults.Accepted($"/internal/ledger/orchestrator-export/{record.RunId}", response); }) .WithName("OrchestratorExportRecord") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/internal/ledger/orchestrator-export/{artifactHash}", async Task>, ProblemHttpResult>> ( HttpContext httpContext, string artifactHash, OrchestratorExportService service, CancellationToken cancellationToken) => { if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant"); } var records = await service.GetByArtifactAsync(tenantValues.ToString(), artifactHash, cancellationToken).ConfigureAwait(false); return TypedResults.Json(records); }) .WithName("OrchestratorExportQuery") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/internal/ledger/airgap-import", async Task, ProblemHttpResult>> ( HttpContext httpContext, AirgapImportRequest request, AirgapImportService service, CancellationToken cancellationToken) => { if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant"); } var input = new AirgapImportInput( tenantValues.ToString(), request.BundleId, request.MirrorGeneration, request.MerkleRoot, request.TimeAnchor, request.Publisher, request.HashAlgorithm, request.Contents ?? Array.Empty(), request.ImportOperator); var result = await service.RecordAsync(input, cancellationToken).ConfigureAwait(false); if (!result.Success) { return TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "airgap_import_failed", detail: result.Error ?? "Failed to record air-gap import."); } var response = new AirgapImportResponse(result.ChainId, result.SequenceNumber, result.LedgerEventId, "accepted", null); return TypedResults.Accepted($"/internal/ledger/airgap-import/{request.BundleId}", response); }) .WithName("AirgapImportRecord") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status202Accepted) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict); // Attestation Pointer Endpoints (LEDGER-ATTEST-73-001) app.MapPost("/v1/ledger/attestation-pointers", async Task, Ok, ProblemHttpResult>> ( HttpContext httpContext, CreateAttestationPointerRequest request, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } try { var input = request.ToInput(tenantId); var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false); var response = new CreateAttestationPointerResponse( result.Success, result.PointerId?.ToString(), result.LedgerEventId?.ToString(), result.Error); if (!result.Success) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "attestation_pointer_failed", detail: result.Error); } return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response); } catch (ArgumentException ex) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_request", detail: ex.Message); } }) .WithName("CreateAttestationPointer") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string pointerId, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (!Guid.TryParse(pointerId, out var pointerGuid)) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_pointer_id", detail: "Pointer ID must be a valid GUID."); } var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false); if (pointer is null) { return TypedResults.NotFound(); } return TypedResults.Json(pointer.ToResponse()); }) .WithName("GetAttestationPointer") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task>, ProblemHttpResult>> ( HttpContext httpContext, string findingId, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false); IReadOnlyList responseList = pointers.Select(p => p.ToResponse()).ToList(); return TypedResults.Json(responseList); }) .WithName("GetFindingAttestationPointers") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task, ProblemHttpResult>> ( HttpContext httpContext, string findingId, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false); return TypedResults.Json(summary.ToResponse()); }) .WithName("GetFindingAttestationSummary") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/v1/ledger/attestation-pointers/search", async Task, ProblemHttpResult>> ( HttpContext httpContext, AttestationPointerSearchRequest request, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } try { var query = request.ToQuery(tenantId); var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false); var response = new AttestationPointerSearchResponse( pointers.Select(p => p.ToResponse()).ToList(), pointers.Count); return TypedResults.Json(response); } catch (ArgumentException ex) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_request", detail: ex.Message); } }) .WithName("SearchAttestationPointers") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task> ( HttpContext httpContext, string pointerId, UpdateVerificationResultRequest request, AttestationPointerService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (!Guid.TryParse(pointerId, out var pointerGuid)) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_pointer_id", detail: "Pointer ID must be a valid GUID."); } try { var verificationResult = request.VerificationResult.ToModel(); var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false); if (!success) { return TypedResults.NotFound(); } return TypedResults.NoContent(); } catch (ArgumentException ex) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_request", detail: ex.Message); } }) .WithName("UpdateAttestationPointerVerification") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/.well-known/openapi", async (HttpContext context) => { var contentRoot = AppContext.BaseDirectory; var specPath = OpenApiMetadataFactory.GetSpecPath(contentRoot); if (!File.Exists(specPath)) { return Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: "openapi_missing", detail: "OpenAPI document not found on server."); } var specBytes = await File.ReadAllBytesAsync(specPath, context.RequestAborted).ConfigureAwait(false); var etag = OpenApiMetadataFactory.ComputeEtag(specBytes); if (context.Request.Headers.IfNoneMatch.Any(match => string.Equals(match, etag, StringComparison.Ordinal))) { return Results.StatusCode(StatusCodes.Status304NotModified); } context.Response.Headers.ETag = etag; context.Response.Headers.CacheControl = "public, max-age=300, must-revalidate"; context.Response.Headers.Append("X-Api-Version", OpenApiMetadataFactory.ApiVersion); context.Response.Headers.Append("X-Build-Version", OpenApiMetadataFactory.GetBuildVersion()); var lastModified = OpenApiMetadataFactory.GetLastModified(specPath); if (lastModified.HasValue) { context.Response.Headers.LastModified = lastModified.Value.ToString("R"); } return Results.Text(Encoding.UTF8.GetString(specBytes), "application/yaml"); }) .WithName("LedgerOpenApiDocument") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status304NotModified) .ProducesProblem(StatusCodes.Status500InternalServerError); // Snapshot Endpoints (LEDGER-PACKS-42-001-DEV) app.MapPost("/v1/ledger/snapshots", async Task, ProblemHttpResult>> ( HttpContext httpContext, CreateSnapshotRequest request, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var input = request.ToInput(tenantId); var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false); var response = new CreateSnapshotResponse( result.Success, result.Snapshot?.ToResponse(), result.Error); if (!result.Success) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "snapshot_creation_failed", detail: result.Error); } return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response); }) .WithName("CreateSnapshot") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/snapshots", async Task, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var statusStr = httpContext.Request.Query["status"].ToString(); Domain.SnapshotStatus? status = null; if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse(statusStr, true, out var parsedStatus)) { status = parsedStatus; } var query = new Domain.SnapshotListQuery( tenantId, status, ParseDate(httpContext.Request.Query["created_after"]), ParseDate(httpContext.Request.Query["created_before"]), ParseInt(httpContext.Request.Query["page_size"]) ?? 100, httpContext.Request.Query["page_token"].ToString()); var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false); var response = new SnapshotListResponse( snapshots.Select(s => s.ToResponse()).ToList(), nextPageToken); return TypedResults.Json(response); }) .WithName("ListSnapshots") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string snapshotId, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (!Guid.TryParse(snapshotId, out var snapshotGuid)) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_snapshot_id", detail: "Snapshot ID must be a valid GUID."); } var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false); if (snapshot is null) { return TypedResults.NotFound(); } return TypedResults.Json(snapshot.ToResponse()); }) .WithName("GetSnapshot") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task> ( HttpContext httpContext, string snapshotId, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (!Guid.TryParse(snapshotId, out var snapshotGuid)) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_snapshot_id", detail: "Snapshot ID must be a valid GUID."); } var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false); if (!deleted) { return TypedResults.NotFound(); } return TypedResults.NoContent(); }) .WithName("DeleteSnapshot") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); // Time-Travel Query Endpoints app.MapGet("/v1/ledger/time-travel/findings", async Task>, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var request = new HistoricalQueryApiRequest( AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]), AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]), SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]), Status: httpContext.Request.Query["status"].ToString(), SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]), SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]), PolicyVersion: httpContext.Request.Query["policy_version"].ToString(), ArtifactId: httpContext.Request.Query["artifact_id"].ToString(), VulnId: httpContext.Request.Query["vuln_id"].ToString(), PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500, PageToken: httpContext.Request.Query["page_token"].ToString()); var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding); var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false); var response = new HistoricalQueryApiResponse( result.QueryPoint.ToResponse(), "Finding", result.Items.Select(i => i.ToResponse()).ToList(), result.NextPageToken, result.TotalCount); return TypedResults.Json(response); }) .WithName("TimeTravelQueryFindings") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/time-travel/vex", async Task>, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var request = new HistoricalQueryApiRequest( AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]), AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]), SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]), PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500, PageToken: httpContext.Request.Query["page_token"].ToString()); var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex); var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false); var response = new HistoricalQueryApiResponse( result.QueryPoint.ToResponse(), "Vex", result.Items.Select(i => i.ToResponse()).ToList(), result.NextPageToken, result.TotalCount); return TypedResults.Json(response); }) .WithName("TimeTravelQueryVex") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/ledger/time-travel/advisories", async Task>, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var request = new HistoricalQueryApiRequest( AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]), AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]), SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]), PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500, PageToken: httpContext.Request.Query["page_token"].ToString()); var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory); var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false); var response = new HistoricalQueryApiResponse( result.QueryPoint.ToResponse(), "Advisory", result.Items.Select(i => i.ToResponse()).ToList(), result.NextPageToken, result.TotalCount); return TypedResults.Json(response); }) .WithName("TimeTravelQueryAdvisories") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // Replay Endpoint app.MapPost("/v1/ledger/replay", async Task, ProblemHttpResult>> ( HttpContext httpContext, ReplayApiRequest request, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var domainRequest = request.ToRequest(tenantId); var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false); var response = new ReplayApiResponse( events.Select(e => e.ToResponse()).ToList(), metadata.ToResponse()); return TypedResults.Json(response); }) .WithName("ReplayEvents") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // Diff Endpoint app.MapPost("/v1/ledger/diff", async Task, ProblemHttpResult>> ( HttpContext httpContext, DiffApiRequest request, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var domainRequest = request.ToRequest(tenantId); var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false); var response = new DiffApiResponse( result.FromPoint.ToResponse(), result.ToPoint.ToResponse(), result.Summary.ToResponse(), result.Changes?.Select(c => c.ToResponse()).ToList(), result.NextPageToken); return TypedResults.Json(response); }) .WithName("ComputeDiff") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // Changelog Endpoint app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task>, ProblemHttpResult>> ( HttpContext httpContext, string entityType, string entityId, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (!Enum.TryParse(entityType, true, out var parsedEntityType)) { return TypedResults.Problem( statusCode: StatusCodes.Status400BadRequest, title: "invalid_entity_type", detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence."); } var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100; var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false); IReadOnlyList response = changelog.Select(e => e.ToResponse()).ToList(); return TypedResults.Json(response); }) .WithName("GetChangelog") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // Staleness Check Endpoint app.MapGet("/v1/ledger/staleness", async Task, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60; var threshold = TimeSpan.FromMinutes(thresholdMinutes); var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result.ToResponse()); }) .WithName("CheckStaleness") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // Current Point Endpoint app.MapGet("/v1/ledger/current-point", async Task, ProblemHttpResult>> ( HttpContext httpContext, SnapshotService service, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false); return TypedResults.Json(point.ToResponse()); }) .WithName("GetCurrentPoint") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); // VexLens Consensus Endpoints (UI-PROOF-VEX-0215-010) app.MapPost("/v1/vex-consensus/compute", async Task, ProblemHttpResult>> ( HttpContext httpContext, ComputeVexConsensusRequest request, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var result = await consensusService.ComputeConsensusAsync(tenantId, request, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("ComputeVexConsensus") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/v1/vex-consensus/compute-batch", async Task, ProblemHttpResult>> ( HttpContext httpContext, ComputeVexConsensusBatchRequest request, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var result = await consensusService.ComputeConsensusBatchAsync(tenantId, request, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("ComputeVexConsensusBatch") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/projections/{projectionId}", async Task, NotFound, ProblemHttpResult>> ( string projectionId, VexConsensusService consensusService, CancellationToken cancellationToken) => { var result = await consensusService.GetProjectionAsync(projectionId, cancellationToken).ConfigureAwait(false); if (result is null) { return TypedResults.NotFound(); } return TypedResults.Json(result); }) .WithName("GetVexProjection") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/projections", async Task, ProblemHttpResult>> ( HttpContext httpContext, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var request = new QueryVexProjectionsRequest( VulnerabilityId: httpContext.Request.Query["vulnerability_id"].ToString(), ProductKey: httpContext.Request.Query["product_key"].ToString(), Status: httpContext.Request.Query["status"].ToString(), Outcome: httpContext.Request.Query["outcome"].ToString(), MinimumConfidence: ParseDecimal(httpContext.Request.Query["min_confidence"].ToString()) is decimal d ? (double)d : null, ComputedAfter: ParseDate(httpContext.Request.Query["computed_after"].ToString()), ComputedBefore: ParseDate(httpContext.Request.Query["computed_before"].ToString()), StatusChanged: ParseBool(httpContext.Request.Query["status_changed"].ToString()), Limit: ParseInt(httpContext.Request.Query["limit"].ToString()), Offset: ParseInt(httpContext.Request.Query["offset"].ToString()), SortBy: httpContext.Request.Query["sort_by"].ToString(), SortDescending: ParseBool(httpContext.Request.Query["sort_desc"].ToString())); var result = await consensusService.QueryProjectionsAsync(tenantId, request, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("QueryVexProjections") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/projections/latest", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var vulnId = httpContext.Request.Query["vulnerability_id"].ToString(); var productKey = httpContext.Request.Query["product_key"].ToString(); if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required."); } var result = await consensusService.GetLatestProjectionAsync(tenantId, vulnId, productKey, cancellationToken).ConfigureAwait(false); if (result is null) { return TypedResults.NotFound(); } return TypedResults.Json(result); }) .WithName("GetLatestVexProjection") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/history", async Task, ProblemHttpResult>> ( HttpContext httpContext, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var vulnId = httpContext.Request.Query["vulnerability_id"].ToString(); var productKey = httpContext.Request.Query["product_key"].ToString(); var limit = ParseInt(httpContext.Request.Query["limit"].ToString()); if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required."); } var result = await consensusService.GetProjectionHistoryAsync(tenantId, vulnId, productKey, limit, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("GetVexProjectionHistory") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/statistics", async Task, ProblemHttpResult>> ( HttpContext httpContext, VexConsensusService consensusService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var result = await consensusService.GetStatisticsAsync(tenantId, cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("GetVexConsensusStatistics") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/issuers", async Task, ProblemHttpResult>> ( HttpContext httpContext, VexConsensusService consensusService, CancellationToken cancellationToken) => { var result = await consensusService.ListIssuersAsync( httpContext.Request.Query["category"].ToString(), httpContext.Request.Query["min_trust_tier"].ToString(), httpContext.Request.Query["status"].ToString(), httpContext.Request.Query["search"].ToString(), ParseInt(httpContext.Request.Query["limit"].ToString()), ParseInt(httpContext.Request.Query["offset"].ToString()), cancellationToken).ConfigureAwait(false); return TypedResults.Json(result); }) .WithName("ListVexIssuers") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/vex-consensus/issuers/{issuerId}", async Task, NotFound, ProblemHttpResult>> ( string issuerId, VexConsensusService consensusService, CancellationToken cancellationToken) => { var result = await consensusService.GetIssuerAsync(issuerId, cancellationToken).ConfigureAwait(false); if (result is null) { return TypedResults.NotFound(); } return TypedResults.Json(result); }) .WithName("GetVexIssuer") .RequireAuthorization(LedgerExportPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); // Alert Triage Endpoints (SPRINT_3602) const string AlertReadPolicy = LedgerExportPolicy; const string AlertDecidePolicy = LedgerWritePolicy; app.MapGet("/v1/alerts", async Task, ProblemHttpResult>> ( HttpContext httpContext, IAlertService alertService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var filter = new AlertFilterOptions( Band: httpContext.Request.Query["band"].ToString(), Severity: httpContext.Request.Query["severity"].ToString(), Status: httpContext.Request.Query["status"].ToString(), ArtifactId: httpContext.Request.Query["artifact_id"].ToString(), VulnId: httpContext.Request.Query["vuln_id"].ToString(), ComponentPurl: httpContext.Request.Query["component_purl"].ToString(), Limit: ParseInt(httpContext.Request.Query["limit"]) ?? 50, Offset: ParseInt(httpContext.Request.Query["offset"]) ?? 0, SortBy: httpContext.Request.Query["sort_by"].ToString(), SortDescending: ParseBool(httpContext.Request.Query["sort_desc"]) ?? false); var result = await alertService.ListAsync(tenantId, filter, cancellationToken).ConfigureAwait(false); var response = new AlertListResponse( result.Items.Select(a => new AlertSummary { AlertId = a.AlertId, ArtifactId = a.ArtifactId, VulnId = a.VulnId, ComponentPurl = a.ComponentPurl, Severity = a.Severity, Band = a.Band, Status = a.Status, Score = a.Score, CreatedAt = a.CreatedAt, UpdatedAt = a.UpdatedAt, DecisionCount = a.DecisionCount }).ToList(), result.TotalCount, result.NextPageToken); return TypedResults.Json(response); }) .WithName("ListAlerts") .RequireAuthorization(AlertReadPolicy) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/alerts/{alertId}", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string alertId, IAlertService alertService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false); if (alert is null) { return TypedResults.NotFound(); } var response = new AlertSummary { AlertId = alert.AlertId, ArtifactId = alert.ArtifactId, VulnId = alert.VulnId, ComponentPurl = alert.ComponentPurl, Severity = alert.Severity, Band = alert.Band, Status = alert.Status, Score = alert.Score, CreatedAt = alert.CreatedAt, UpdatedAt = alert.UpdatedAt, DecisionCount = alert.DecisionCount }; return TypedResults.Json(response); }) .WithName("GetAlert") .RequireAuthorization(AlertReadPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/v1/alerts/{alertId}/decisions", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string alertId, DecisionRequest request, IAlertService alertService, IDecisionService decisionService, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } // Validate alert exists var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false); if (alert is null) { return TypedResults.NotFound(); } // Get actor from auth context var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous"; // Generate simple replay token var tokenInput = $"{alertId}|{actorId}|{request.DecisionStatus}|{timeProvider.GetUtcNow():O}"; var replayToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tokenInput))).ToLowerInvariant(); // Record decision (append-only) var decision = await decisionService.RecordAsync(new DecisionEvent { AlertId = alertId, ArtifactId = alert.ArtifactId, ActorId = actorId, Timestamp = timeProvider.GetUtcNow(), DecisionStatus = request.DecisionStatus, ReasonCode = request.ReasonCode, ReasonText = request.ReasonText, EvidenceHashes = request.EvidenceHashes?.ToList() ?? new(), PolicyContext = request.PolicyContext, ReplayToken = replayToken }, cancellationToken).ConfigureAwait(false); var response = new DecisionResponse { DecisionId = decision.Id, AlertId = decision.AlertId, ActorId = decision.ActorId, Timestamp = decision.Timestamp, ReplayToken = decision.ReplayToken, EvidenceHashes = decision.EvidenceHashes }; return TypedResults.Created($"/v1/alerts/{alertId}/audit", response); }) .WithName("RecordDecision") .RequireAuthorization(AlertDecidePolicy) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapGet("/v1/alerts/{alertId}/audit", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string alertId, IDecisionService decisionService, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } var decisions = await decisionService.GetHistoryAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false); var events = decisions.Select(d => new AuditEventResponse { EventId = d.Id, EventType = "decision_recorded", ActorId = d.ActorId, Timestamp = d.Timestamp, Details = new { decision_status = d.DecisionStatus, reason_code = d.ReasonCode, reason_text = d.ReasonText, evidence_hashes = d.EvidenceHashes }, ReplayToken = d.ReplayToken }).ToList(); var response = new AuditTimelineResponse { AlertId = alertId, Events = events, TotalCount = events.Count }; return TypedResults.Json(response); }) .WithName("GetAlertAudit") .RequireAuthorization(AlertReadPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); // Sprint: SPRINT_3602_0001_0001 - Task 9: Bundle download endpoint app.MapGet("/v1/alerts/{alertId}/bundle", async Task> ( string alertId, [FromServices] IAlertService alertService, [FromServices] IEvidenceBundleService bundleService, CancellationToken cancellationToken) => { var alert = await alertService.GetAlertAsync(alertId, cancellationToken).ConfigureAwait(false); if (alert is null) { return TypedResults.NotFound(); } var bundle = await bundleService.CreateBundleAsync(alertId, cancellationToken).ConfigureAwait(false); if (bundle is null) { return TypedResults.Problem( detail: "Failed to create evidence bundle", statusCode: StatusCodes.Status500InternalServerError); } return TypedResults.File( bundle.Content, contentType: "application/gzip", fileDownloadName: $"evidence-{alertId}.tar.gz"); }) .WithName("DownloadAlertBundle") .RequireAuthorization(AlertReadPolicy) .Produces(StatusCodes.Status200OK, "application/gzip") .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); // Sprint: SPRINT_3602_0001_0001 - Task 10: Bundle verify endpoint app.MapPost("/v1/alerts/{alertId}/bundle/verify", async Task, NotFound, ProblemHttpResult>> ( string alertId, [FromBody] BundleVerificationRequest request, [FromServices] IAlertService alertService, [FromServices] IEvidenceBundleService bundleService, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) => { var alert = await alertService.GetAlertAsync(alertId, cancellationToken).ConfigureAwait(false); if (alert is null) { return TypedResults.NotFound(); } var result = await bundleService.VerifyBundleAsync( alertId, request.BundleHash, request.Signature, cancellationToken).ConfigureAwait(false); var response = new BundleVerificationResponse { AlertId = alertId, IsValid = result.IsValid, VerifiedAt = timeProvider.GetUtcNow(), SignatureValid = result.SignatureValid, HashValid = result.HashValid, ChainValid = result.ChainValid, Errors = result.Errors }; return TypedResults.Ok(response); }) .WithName("VerifyAlertBundle") .RequireAuthorization(AlertReadPolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); app.MapPost("/v1/vex-consensus/issuers", async Task, ProblemHttpResult>> ( RegisterVexIssuerRequest request, VexConsensusService consensusService, CancellationToken cancellationToken) => { var result = await consensusService.RegisterIssuerAsync(request, cancellationToken).ConfigureAwait(false); return TypedResults.Created($"/v1/vex-consensus/issuers/{result.IssuerId}", result); }) .WithName("RegisterVexIssuer") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest); // PATCH /api/v1/findings/{findingId}/state - SPRINT_4000_0100_0002 app.MapPatch("/api/v1/findings/{findingId}/state", async Task, NotFound, ProblemHttpResult>> ( HttpContext httpContext, string findingId, StateTransitionRequest request, ILedgerEventWriteService writeService, ILedgerEventRepository eventRepository, TimeProvider timeProvider, CancellationToken cancellationToken) => { if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId)) { return tenantProblem!; } if (string.IsNullOrWhiteSpace(findingId)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_finding_id", detail: "Finding ID is required."); } if (string.IsNullOrWhiteSpace(request.TargetState)) { return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_target_state", detail: "Target state is required."); } var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous"; var actorType = httpContext.User.FindFirst("actor_type")?.Value ?? "user"; var evidenceRefs = await eventRepository.GetEvidenceReferencesAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false); var artifactId = "unknown"; var chainId = Guid.NewGuid(); var previousStatus = "affected"; long sequenceNumber = 1; var latestEvidenceRef = evidenceRefs.FirstOrDefault(); if (latestEvidenceRef != null) { var latestEvent = await eventRepository.GetByEventIdAsync(tenantId, latestEvidenceRef.EventId, cancellationToken).ConfigureAwait(false); if (latestEvent != null) { artifactId = latestEvent.ArtifactId; chainId = latestEvent.ChainId; sequenceNumber = latestEvent.SequenceNumber + 1; } } var targetState = request.TargetState.ToLowerInvariant().Trim(); var now = timeProvider.GetUtcNow(); var payload = new JsonObject { ["status"] = targetState, ["previous_status"] = previousStatus }; if (!string.IsNullOrWhiteSpace(request.Justification)) payload["justification"] = request.Justification; if (!string.IsNullOrWhiteSpace(request.Notes)) payload["notes"] = request.Notes; if (request.DueDate.HasValue) payload["due_date"] = request.DueDate.Value.ToString("O", CultureInfo.InvariantCulture); if (request.Tags is { Count: > 0 }) { var tagsArray = new JsonArray(); foreach (var tag in request.Tags) tagsArray.Add(tag); payload["tags"] = tagsArray; } var eventEnvelope = new JsonObject { ["event"] = new JsonObject { ["eventType"] = LedgerEventConstants.EventFindingStatusChanged, ["payload"] = payload } }; var draft = new LedgerEventDraft( TenantId: tenantId, ChainId: chainId, SequenceNumber: sequenceNumber, EventId: Guid.NewGuid(), EventType: LedgerEventConstants.EventFindingStatusChanged, PolicyVersion: "1", FindingId: findingId, ArtifactId: artifactId, SourceRunId: null, ActorId: actorId, ActorType: actorType, OccurredAt: now, RecordedAt: now, Payload: payload, CanonicalEnvelope: eventEnvelope, ProvidedPreviousHash: null); var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); if (result.Status == LedgerWriteStatus.ValidationFailed) return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "validation_failed", detail: string.Join("; ", result.Errors)); if (result.Status == LedgerWriteStatus.Conflict) return TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.ConflictCode ?? "conflict", detail: string.Join("; ", result.Errors)); var response = new StateTransitionResponse { FindingId = findingId, PreviousState = previousStatus, CurrentState = targetState, TransitionRecordedAt = now, ActorId = actorId, Justification = request.Justification, Notes = request.Notes, DueDate = request.DueDate, Tags = request.Tags, EventId = result.Record?.EventId }; return TypedResults.Ok(response); }) .WithName("TransitionFindingState") .RequireAuthorization(LedgerWritePolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerOptions); // Findings summary, evidence graph, reachability, and runtime timeline endpoints app.MapFindingSummaryEndpoints(); app.MapEvidenceGraphEndpoints(); app.MapReachabilityMapEndpoints(); app.MapRuntimeTimelineEndpoints(); // Map EWS scoring and webhook endpoints (SPRINT_8200.0012.0004) app.MapScoringEndpoints(); app.MapWebhookEndpoints(); app.Run(); static Created CreateCreatedResponse(LedgerEventRecord record) { var response = CreateResponse(record, "created"); return TypedResults.Created($"/vuln/ledger/events/{record.EventId}", response); } static LedgerEventResponse CreateResponse(LedgerEventRecord record, string status) => new() { EventId = record.EventId, ChainId = record.ChainId, Sequence = record.SequenceNumber, Status = status, EventHash = record.EventHash, PreviousHash = record.PreviousHash, MerkleLeafHash = record.MerkleLeafHash, RecordedAt = record.RecordedAt }; static async Task>, ProblemHttpResult>> WritePagedResponse( HttpContext httpContext, ExportPage page, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(page.NextPageToken)) { httpContext.Response.Headers["X-Stella-Next-Page-Token"] = page.NextPageToken; } httpContext.Response.Headers["X-Stella-Result-Count"] = page.Items.Count.ToString(); var acceptsNdjson = httpContext.Request.Headers.Accept.Any(h => h?.Contains("application/x-ndjson", StringComparison.OrdinalIgnoreCase) == true); if (acceptsNdjson) { httpContext.Response.ContentType = "application/x-ndjson"; var stream = new MemoryStream(); await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { SkipValidation = false, Indented = false }); foreach (var item in page.Items) { JsonSerializer.Serialize(writer, item); writer.Flush(); await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); } stream.Position = 0; return TypedResults.Stream(stream, contentType: "application/x-ndjson"); } return TypedResults.Json(page); } static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId) { tenantId = string.Empty; if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues)) { problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant"); return false; } tenantId = tenantValues.ToString(); problem = null; return true; } static int? ParseInt(string? value) { return int.TryParse(value, out var result) ? result : null; } static long? ParseLong(string? value) { return long.TryParse(value, out var result) ? result : null; } static DateTimeOffset? ParseDate(string? value) { return DateTimeOffset.TryParse(value, out var result) ? result : null; } static decimal? ParseDecimal(string? value) { return decimal.TryParse(value, out var result) ? result : null; } static bool? ParseBool(string? value) { return bool.TryParse(value, out var result) ? result : null; } static Guid? ParseGuid(string? value) { return Guid.TryParse(value, out var result) ? result : null; } namespace StellaOps.Findings.Ledger.WebService { /// /// Marker class for WebApplicationFactory integration tests. /// public partial class Program { } }