using Microsoft.AspNetCore.Mvc; using StellaOps.VexLens.Api; using StellaOps.VexLens.Delta; using StellaOps.VexLens.NoiseGate; using StellaOps.VexLens.Storage; namespace StellaOps.VexLens.WebService.Extensions; /// /// Extension methods for mapping VexLens API endpoints. /// public static class VexLensEndpointExtensions { private const string TenantHeader = "X-StellaOps-Tenant"; /// /// Maps all VexLens API endpoints. /// public static IEndpointRouteBuilder MapVexLensEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/vexlens") .WithTags("VexLens") .WithOpenApi(); // Consensus endpoints group.MapPost("/consensus", ComputeConsensusAsync) .WithName("ComputeConsensus") .WithDescription("Compute consensus for a vulnerability-product pair") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/consensus:withProof", ComputeConsensusWithProofAsync) .WithName("ComputeConsensusWithProof") .WithDescription("Compute consensus with full proof object for audit trail") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/consensus:batch", ComputeConsensusBatchAsync) .WithName("ComputeConsensusBatch") .WithDescription("Compute consensus for multiple vulnerability-product pairs") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // Projection endpoints group.MapGet("/projections", QueryProjectionsAsync) .WithName("QueryProjections") .WithDescription("Query consensus projections with filtering") .Produces(StatusCodes.Status200OK); group.MapGet("/projections/{projectionId}", GetProjectionAsync) .WithName("GetProjection") .WithDescription("Get a specific consensus projection by ID") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/projections/latest", GetLatestProjectionAsync) .WithName("GetLatestProjection") .WithDescription("Get the latest projection for a vulnerability-product pair") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/projections/history", GetProjectionHistoryAsync) .WithName("GetProjectionHistory") .WithDescription("Get projection history for a vulnerability-product pair") .Produces(StatusCodes.Status200OK); // Statistics endpoint group.MapGet("/stats", GetStatisticsAsync) .WithName("GetVexLensStatistics") .WithDescription("Get consensus projection statistics") .Produces(StatusCodes.Status200OK); // Conflict endpoints group.MapGet("/conflicts", GetConflictsAsync) .WithName("GetConflicts") .WithDescription("Get projections with conflicts") .Produces(StatusCodes.Status200OK); // Delta/Noise-Gating endpoints var deltaGroup = app.MapGroup("/api/v1/vexlens/deltas") .WithTags("VexLens Delta") .WithOpenApi(); deltaGroup.MapPost("/compute", ComputeDeltaAsync) .WithName("ComputeDelta") .WithDescription("Compute delta report between two snapshots") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); var gatingGroup = app.MapGroup("/api/v1/vexlens/gating") .WithTags("VexLens Gating") .WithOpenApi(); gatingGroup.MapGet("/statistics", GetGatingStatisticsAsync) .WithName("GetGatingStatistics") .WithDescription("Get aggregated noise-gating statistics") .Produces(StatusCodes.Status200OK); gatingGroup.MapPost("/snapshots/{snapshotId}/gate", GateSnapshotAsync) .WithName("GateSnapshot") .WithDescription("Apply noise-gating to a snapshot") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // Issuer endpoints var issuerGroup = app.MapGroup("/api/v1/vexlens/issuers") .WithTags("VexLens Issuers") .WithOpenApi(); issuerGroup.MapGet("/", ListIssuersAsync) .WithName("ListIssuers") .WithDescription("List registered VEX issuers") .Produces(StatusCodes.Status200OK); issuerGroup.MapGet("/{issuerId}", GetIssuerAsync) .WithName("GetIssuer") .WithDescription("Get issuer details") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); issuerGroup.MapPost("/", RegisterIssuerAsync) .WithName("RegisterIssuer") .WithDescription("Register a new VEX issuer") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); issuerGroup.MapDelete("/{issuerId}", RevokeIssuerAsync) .WithName("RevokeIssuer") .WithDescription("Revoke an issuer") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); issuerGroup.MapPost("/{issuerId}/keys", AddIssuerKeyAsync) .WithName("AddIssuerKey") .WithDescription("Add a key to an issuer") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); issuerGroup.MapDelete("/{issuerId}/keys/{fingerprint}", RevokeIssuerKeyAsync) .WithName("RevokeIssuerKey") .WithDescription("Revoke an issuer key") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); return app; } private static string? GetTenantId(HttpContext context) { return context.Request.Headers.TryGetValue(TenantHeader, out var value) ? value.ToString() : null; } // Consensus handlers private static async Task ComputeConsensusAsync( [FromBody] ComputeConsensusRequest request, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context) ?? request.TenantId; var requestWithTenant = request with { TenantId = tenantId }; var result = await service.ComputeConsensusAsync(requestWithTenant, cancellationToken); return Results.Ok(result); } private static async Task ComputeConsensusWithProofAsync( [FromBody] ComputeConsensusWithProofRequest request, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context) ?? request.TenantId; var requestWithTenant = request with { TenantId = tenantId }; var result = await service.ComputeConsensusWithProofAsync(requestWithTenant, cancellationToken); return Results.Ok(result); } private static async Task ComputeConsensusBatchAsync( [FromBody] ComputeConsensusBatchRequest request, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context) ?? request.TenantId; var requestWithTenant = request with { TenantId = tenantId }; var result = await service.ComputeConsensusBatchAsync(requestWithTenant, cancellationToken); return Results.Ok(result); } // Projection handlers private static async Task QueryProjectionsAsync( [AsParameters] ProjectionQueryParams query, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var request = new QueryProjectionsRequest( VulnerabilityId: query.VulnerabilityId, ProductKey: query.ProductKey, Status: null, Outcome: query.Outcome, MinimumConfidence: query.MinimumConfidence, ComputedAfter: query.ComputedAfter, ComputedBefore: query.ComputedBefore, StatusChanged: query.StatusChanged, Limit: query.Limit, Offset: query.Offset, SortBy: query.SortBy, SortDescending: query.SortDescending); var result = await service.QueryProjectionsAsync(request, tenantId, cancellationToken); return Results.Ok(result); } private static async Task GetProjectionAsync( string projectionId, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var result = await service.GetProjectionAsync(projectionId, cancellationToken); return result != null ? Results.Ok(result) : Results.NotFound(); } private static async Task GetLatestProjectionAsync( [FromQuery] string vulnerabilityId, [FromQuery] string productKey, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var result = await service.GetLatestProjectionAsync(vulnerabilityId, productKey, tenantId, cancellationToken); return result != null ? Results.Ok(result) : Results.NotFound(); } private static async Task GetProjectionHistoryAsync( [FromQuery] string vulnerabilityId, [FromQuery] string productKey, [FromQuery] int? limit, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var result = await service.GetProjectionHistoryAsync(vulnerabilityId, productKey, tenantId, limit, cancellationToken); return Results.Ok(result); } private static async Task GetStatisticsAsync( [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); var result = await service.GetStatisticsAsync(tenantId, cancellationToken); return Results.Ok(result); } private static async Task GetConflictsAsync( [FromQuery] int? limit, [FromQuery] int? offset, [FromServices] IVexLensApiService service, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context); // Query for projections with conflicts (conflictCount > 0) var request = new QueryProjectionsRequest( VulnerabilityId: null, ProductKey: null, Status: null, Outcome: null, MinimumConfidence: null, ComputedAfter: null, ComputedBefore: null, StatusChanged: null, Limit: limit ?? 50, Offset: offset ?? 0, SortBy: "ComputedAt", SortDescending: true); var result = await service.QueryProjectionsAsync(request, tenantId, cancellationToken); // Filter to only show projections with conflicts var conflictsOnly = new QueryProjectionsResponse( Projections: result.Projections.Where(p => p.ConflictCount > 0).ToList(), TotalCount: result.Projections.Count(p => p.ConflictCount > 0), Offset: result.Offset, Limit: result.Limit); return Results.Ok(conflictsOnly); } // Delta/Noise-Gating handlers private static async Task ComputeDeltaAsync( [FromBody] ComputeDeltaRequest request, [FromServices] INoiseGate noiseGate, [FromServices] ISnapshotStore snapshotStore, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context) ?? request.TenantId; // Get snapshots var fromSnapshot = await snapshotStore.GetAsync(request.FromSnapshotId, tenantId, cancellationToken); var toSnapshot = await snapshotStore.GetAsync(request.ToSnapshotId, tenantId, cancellationToken); if (fromSnapshot is null || toSnapshot is null) { return Results.BadRequest("One or both snapshot IDs not found"); } // Compute delta var options = NoiseGatingApiMapper.MapOptions(request.Options); var delta = await noiseGate.DiffAsync(fromSnapshot, toSnapshot, options, cancellationToken); return Results.Ok(NoiseGatingApiMapper.MapToResponse(delta)); } private static async Task GetGatingStatisticsAsync( [FromQuery] string? tenantId, [FromQuery] DateTimeOffset? fromDate, [FromQuery] DateTimeOffset? toDate, [FromServices] IGatingStatisticsStore statsStore, HttpContext context, CancellationToken cancellationToken) { var tenant = GetTenantId(context) ?? tenantId; var stats = await statsStore.GetAggregatedAsync(tenant, fromDate, toDate, cancellationToken); return Results.Ok(new AggregatedGatingStatisticsResponse( TotalSnapshots: stats.TotalSnapshots, TotalEdgesProcessed: stats.TotalEdgesProcessed, TotalEdgesAfterDedup: stats.TotalEdgesAfterDedup, AverageEdgeReductionPercent: stats.AverageEdgeReductionPercent, TotalVerdicts: stats.TotalVerdicts, TotalSurfaced: stats.TotalSurfaced, TotalDamped: stats.TotalDamped, AverageDampingPercent: stats.AverageDampingPercent, ComputedAt: DateTimeOffset.UtcNow)); } private static async Task GateSnapshotAsync( string snapshotId, [FromBody] GateSnapshotRequest request, [FromServices] INoiseGate noiseGate, [FromServices] ISnapshotStore snapshotStore, HttpContext context, CancellationToken cancellationToken) { var tenantId = GetTenantId(context) ?? request.TenantId; // Get the raw snapshot var snapshot = await snapshotStore.GetRawAsync(snapshotId, tenantId, cancellationToken); if (snapshot is null) { return Results.NotFound(); } // Apply noise-gating var gateRequest = new NoiseGateRequest { Graph = snapshot.Graph, SnapshotId = snapshotId, Verdicts = snapshot.Verdicts }; var gatedSnapshot = await noiseGate.GateAsync(gateRequest, cancellationToken); return Results.Ok(new GatedSnapshotResponse( SnapshotId: gatedSnapshot.SnapshotId, Digest: gatedSnapshot.Digest, CreatedAt: gatedSnapshot.CreatedAt, EdgeCount: gatedSnapshot.Edges.Count, VerdictCount: gatedSnapshot.Verdicts.Count, Statistics: NoiseGatingApiMapper.MapStatistics(gatedSnapshot.Statistics))); } // Issuer handlers private static async Task ListIssuersAsync( [FromQuery] string? category, [FromQuery] string? minimumTrustTier, [FromQuery] string? status, [FromQuery] string? search, [FromQuery] int? limit, [FromQuery] int? offset, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var result = await service.ListIssuersAsync( category, minimumTrustTier, status, search, limit, offset, cancellationToken); return Results.Ok(result); } private static async Task GetIssuerAsync( string issuerId, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var result = await service.GetIssuerAsync(issuerId, cancellationToken); return result != null ? Results.Ok(result) : Results.NotFound(); } private static async Task RegisterIssuerAsync( [FromBody] RegisterIssuerRequest request, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var result = await service.RegisterIssuerAsync(request, cancellationToken); return Results.Created($"/api/v1/vexlens/issuers/{result.IssuerId}", result); } private static async Task RevokeIssuerAsync( string issuerId, [FromBody] RevokeRequest request, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var success = await service.RevokeIssuerAsync(issuerId, request, cancellationToken); return success ? Results.NoContent() : Results.NotFound(); } private static async Task AddIssuerKeyAsync( string issuerId, [FromBody] RegisterKeyRequest request, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var result = await service.AddIssuerKeyAsync(issuerId, request, cancellationToken); return Results.Ok(result); } private static async Task RevokeIssuerKeyAsync( string issuerId, string fingerprint, [FromBody] RevokeRequest request, [FromServices] IVexLensApiService service, CancellationToken cancellationToken) { var success = await service.RevokeIssuerKeyAsync(issuerId, fingerprint, request, cancellationToken); return success ? Results.NoContent() : Results.NotFound(); } } /// /// Query parameters for projection queries. /// public sealed record ProjectionQueryParams( [FromQuery] string? VulnerabilityId, [FromQuery] string? ProductKey, [FromQuery] string? Outcome, [FromQuery] double? MinimumConfidence, [FromQuery] DateTimeOffset? ComputedAfter, [FromQuery] DateTimeOffset? ComputedBefore, [FromQuery] bool? StatusChanged, [FromQuery] int? Limit, [FromQuery] int? Offset, [FromQuery] string? SortBy, [FromQuery] bool? SortDescending);