Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs
2026-01-08 20:46:43 +02:00

2035 lines
80 KiB
C#

#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>(
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<LedgerServiceOptions>()
.Bind(builder.Configuration.GetSection(LedgerServiceOptions.SectionName))
.PostConfigure(options => options.Validate())
.ValidateOnStart();
builder.Services.AddOptions<LedgerIncidentOptions>()
.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<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
builder.Services.AddSingleton<LedgerIncidentCoordinator>();
builder.Services.AddSingleton<ILedgerIncidentDiagnostics>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
builder.Services.AddSingleton<ILedgerIncidentState>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
builder.Services.AddSingleton<LedgerAnchorQueue>();
builder.Services.AddSingleton<LedgerDataSource>();
builder.Services.AddSingleton<IMerkleAnchorRepository, PostgresMerkleAnchorRepository>();
builder.Services.AddSingleton<ILedgerEventRepository, PostgresLedgerEventRepository>();
builder.Services.AddSingleton<IMerkleAnchorScheduler, PostgresMerkleAnchorScheduler>();
builder.Services.AddSingleton<ILedgerEventStream, PostgresLedgerEventStream>();
builder.Services.AddSingleton<IFindingProjectionRepository, PostgresFindingProjectionRepository>();
builder.Services.AddHttpClient("ledger-policy-engine");
builder.Services.AddSingleton<InlinePolicyEvaluationService>();
builder.Services.AddSingleton<PolicyEvaluationCache>();
builder.Services.AddSingleton<PolicyEngineEvaluationService>();
builder.Services.AddSingleton<IPolicyEvaluationService>(sp => sp.GetRequiredService<PolicyEngineEvaluationService>());
builder.Services.AddSingleton<ILedgerEventWriteService, LedgerEventWriteService>();
builder.Services.AddSingleton<IFindingWorkflowService, FindingWorkflowService>();
builder.Services.AddSingleton<IOrchestratorExportRepository, PostgresOrchestratorExportRepository>();
builder.Services.AddSingleton<OrchestratorExportService>();
builder.Services.AddSingleton<IAirgapImportRepository, PostgresAirgapImportRepository>();
builder.Services.AddSingleton<AirgapImportService>();
builder.Services.AddSingleton<IAttachmentEncryptionService, AttachmentEncryptionService>();
builder.Services.AddSingleton<IAttachmentUrlSigner, AttachmentUrlSigner>();
builder.Services.AddSingleton<IConsoleCsrfValidator, ConsoleCsrfValidator>();
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
builder.Services.AddHostedService<LedgerProjectionWorker>();
builder.Services.AddSingleton<ExportQueryService>();
builder.Services.AddSingleton<AttestationQueryService>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
builder.Services.AddSingleton<AttestationPointerService>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
builder.Services.AddSingleton<SnapshotService>();
builder.Services.AddSingleton<VexConsensusService>();
// Finding summary, evidence graph, reachability, and runtime timeline endpoints
builder.Services.AddSingleton<IFindingSummaryBuilder, FindingSummaryBuilder>();
builder.Services.AddSingleton<IFindingRepository, InMemoryFindingRepository>();
builder.Services.AddSingleton<IFindingSummaryService, FindingSummaryService>();
builder.Services.AddSingleton<IEvidenceRepository, NullEvidenceRepository>();
builder.Services.AddSingleton<IAttestationVerifier, NullAttestationVerifier>();
builder.Services.AddSingleton<IEvidenceGraphBuilder, EvidenceGraphBuilder>();
builder.Services.AddSingleton<IEvidenceContentService, NullEvidenceContentService>();
builder.Services.AddSingleton<IReachabilityMapService, NullReachabilityMapService>();
builder.Services.AddSingleton<IRuntimeTimelineService, NullRuntimeTimelineService>();
// Alert and Decision services (SPRINT_3602)
builder.Services.AddSingleton<IAlertService, AlertService>();
builder.Services.AddSingleton<IDecisionService, DecisionService>();
builder.Services.AddSingleton<IEvidenceBundleService, EvidenceBundleService>();
// Evidence-Weighted Score services (SPRINT_8200.0012.0004)
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
builder.Services.AddSingleton<IFindingScoringService, FindingScoringService>();
// Webhook services (SPRINT_8200.0012.0004 - Wave 6)
builder.Services.AddSingleton<IWebhookStore, InMemoryWebhookStore>();
builder.Services.AddSingleton<IWebhookDeliveryService, WebhookDeliveryService>();
builder.Services.AddHttpClient("webhook-delivery", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("FindingsLedger:Router").Get<StellaRouterOptionsBase>();
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<IExceptionHandlerFeature>();
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<Results<Created<LedgerEventResponse>, Ok<LedgerEventResponse>, 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<FindingExportItem>>, 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<FindingExportItem> 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AttestationExportItem>>, 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<AttestationExportItem> 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<VexExportItem>>, 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<VexExportItem> 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AdvisoryExportItem>>, 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<AdvisoryExportItem> 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<SbomExportItem>>, 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<SbomExportItem> 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<Results<Accepted<OrchestratorExportResponse>, 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<Results<JsonHttpResult<IReadOnlyList<OrchestratorExportRecord>>, 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<Results<Accepted<AirgapImportResponse>, 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<string>(),
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<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, 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<Results<JsonHttpResult<AttestationPointerResponse>, 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<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, 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<AttestationPointerResponse> 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<Results<JsonHttpResult<AttestationSummaryResponse>, 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<Results<JsonHttpResult<AttestationPointerSearchResponse>, 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<Results<NoContent, NotFound, ProblemHttpResult>> (
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<Results<Created<CreateSnapshotResponse>, 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<Results<JsonHttpResult<SnapshotListResponse>, 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<Domain.SnapshotStatus>(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<Results<JsonHttpResult<SnapshotResponse>, 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<Results<NoContent, 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 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<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, 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<FindingHistoryResponse>(
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<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, 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<VexHistoryResponse>(
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<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, 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<AdvisoryHistoryResponse>(
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<Results<JsonHttpResult<ReplayApiResponse>, 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<Results<JsonHttpResult<DiffApiResponse>, 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<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, 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<Domain.EntityType>(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<ChangeLogEntryResponse> 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<Results<JsonHttpResult<StalenessResponse>, 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<Results<JsonHttpResult<QueryPointResponse>, 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<Results<JsonHttpResult<VexConsensusResponse>, 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<Results<JsonHttpResult<VexConsensusBatchResponse>, 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<Results<JsonHttpResult<VexProjectionDetailResponse>, 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<Results<JsonHttpResult<QueryVexProjectionsResponse>, 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<Results<JsonHttpResult<VexProjectionDetailResponse>, 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<Results<JsonHttpResult<VexProjectionHistoryResponse>, 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<Results<JsonHttpResult<VexConsensusStatisticsResponse>, 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<Results<JsonHttpResult<VexIssuerListResponse>, 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<Results<JsonHttpResult<VexIssuerDetailResponse>, 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<Results<JsonHttpResult<AlertListResponse>, 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<Results<JsonHttpResult<AlertSummary>, 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<Results<Created<DecisionResponse>, 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<Results<JsonHttpResult<AuditTimelineResponse>, 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<Results<FileStreamHttpResult, NotFound, ProblemHttpResult>> (
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<FileStreamHttpResult>(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<Results<Ok<BundleVerificationResponse>, 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<Results<Created<VexIssuerDetailResponse>, 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<Results<Ok<StateTransitionResponse>, 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<LedgerEventResponse> 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<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<T>>, ProblemHttpResult>> WritePagedResponse<T>(
HttpContext httpContext,
ExportPage<T> 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
{
/// <summary>
/// Marker class for WebApplicationFactory integration tests.
/// </summary>
public partial class Program { }
}