// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using StellaOps.Platform.WebService.Services; using StellaOps.ReleaseOrchestrator.EvidenceThread.Export; using StellaOps.ReleaseOrchestrator.EvidenceThread.Models; using StellaOps.ReleaseOrchestrator.EvidenceThread.Services; using StellaOps.ReleaseOrchestrator.EvidenceThread.Transcript; using System; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Platform.WebService.Endpoints; /// /// REST endpoints for Evidence Thread operations. /// public static class EvidenceThreadEndpoints { /// /// Maps Evidence Thread API endpoints. /// public static IEndpointRouteBuilder MapEvidenceThreadEndpoints(this IEndpointRouteBuilder app) { var evidence = app.MapGroup("/api/v1/evidence") .WithTags("Evidence Thread"); // GET /api/v1/evidence/{artifactDigest} - Get evidence thread for artifact evidence.MapGet("/{artifactDigest}", GetEvidenceThread) .WithName("GetEvidenceThread") .WithSummary("Get evidence thread for an artifact") .WithDescription("Retrieves the full evidence thread graph for an artifact by its digest.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/evidence/{artifactDigest}/export - Export thread as DSSE bundle evidence.MapPost("/{artifactDigest}/export", ExportEvidenceThread) .WithName("ExportEvidenceThread") .WithSummary("Export evidence thread as DSSE bundle") .WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/evidence/{artifactDigest}/transcript - Generate transcript evidence.MapPost("/{artifactDigest}/transcript", GenerateTranscript) .WithName("GenerateEvidenceTranscript") .WithSummary("Generate natural language transcript") .WithDescription("Generates a natural language transcript explaining the evidence thread.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest); // GET /api/v1/evidence/{artifactDigest}/nodes - Get evidence nodes evidence.MapGet("/{artifactDigest}/nodes", GetEvidenceNodes) .WithName("GetEvidenceNodes") .WithSummary("Get evidence nodes for an artifact") .WithDescription("Retrieves all evidence nodes in the thread.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest); // GET /api/v1/evidence/{artifactDigest}/links - Get evidence links evidence.MapGet("/{artifactDigest}/links", GetEvidenceLinks) .WithName("GetEvidenceLinks") .WithSummary("Get evidence links for an artifact") .WithDescription("Retrieves all evidence links in the thread.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/evidence/{artifactDigest}/collect - Trigger evidence collection evidence.MapPost("/{artifactDigest}/collect", CollectEvidence) .WithName("CollectEvidence") .WithSummary("Collect evidence for an artifact") .WithDescription("Triggers collection of all available evidence for an artifact.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); return app; } private static async Task GetEvidenceThread( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService service, string artifactDigest, [FromQuery] bool? includeContent, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } var options = new EvidenceThreadOptions { IncludeContent = includeContent ?? true }; var graph = await service.GetThreadGraphAsync( requestContext!.TenantId, artifactDigest, options, ct).ConfigureAwait(false); if (graph is null) { return Results.NotFound(new { error = "thread_not_found", artifactDigest }); } return Results.Ok(new EvidenceThreadResponse { ThreadId = graph.Thread.Id, TenantId = graph.Thread.TenantId, ArtifactDigest = graph.Thread.ArtifactDigest, ArtifactName = graph.Thread.ArtifactName, Status = graph.Thread.Status.ToString(), Verdict = graph.Thread.Verdict?.ToString(), RiskScore = graph.Thread.RiskScore, ReachabilityMode = graph.Thread.ReachabilityMode?.ToString(), NodeCount = graph.Nodes.Length, LinkCount = graph.Links.Length, CreatedAt = graph.Thread.CreatedAt, UpdatedAt = graph.Thread.UpdatedAt }); } private static async Task ExportEvidenceThread( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService threadService, IDsseThreadExporter exporter, string artifactDigest, [FromBody] EvidenceExportRequest? request, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } var graph = await threadService.GetThreadGraphAsync( requestContext!.TenantId, artifactDigest, null, ct).ConfigureAwait(false); if (graph is null) { return Results.NotFound(new { error = "thread_not_found", artifactDigest }); } var options = new ThreadExportOptions { Format = ParseExportFormat(request?.Format), IncludeTranscript = request?.IncludeTranscript ?? true, Sign = request?.Sign ?? true, SigningKeyId = request?.SigningKeyId }; var result = await exporter.ExportAsync(graph, options, ct).ConfigureAwait(false); if (!result.Success) { return Results.BadRequest(new { error = result.ErrorCode, message = result.ErrorMessage }); } return Results.Ok(new EvidenceExportResponse { ThreadId = result.ThreadId, Format = result.Format.ToString(), ContentDigest = result.ContentDigest, SizeBytes = result.SizeBytes, SigningKeyId = result.SigningKeyId, ExportedAt = result.ExportedAt, DurationMs = (long)result.Duration.TotalMilliseconds, Envelope = result.Envelope is not null ? new DsseEnvelopeResponse { PayloadType = result.Envelope.PayloadType, Payload = result.Envelope.Payload, PayloadDigest = result.Envelope.PayloadDigest, Signatures = result.Envelope.Signatures.Select(s => new DsseSignatureResponse { KeyId = s.KeyId, Sig = s.Sig, Algorithm = s.Algorithm }).ToList() } : null }); } private static async Task GenerateTranscript( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService threadService, ITranscriptGenerator transcriptGenerator, string artifactDigest, [FromBody] EvidenceTranscriptRequest? request, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } var graph = await threadService.GetThreadGraphAsync( requestContext!.TenantId, artifactDigest, null, ct).ConfigureAwait(false); if (graph is null) { return Results.NotFound(new { error = "thread_not_found", artifactDigest }); } var options = new TranscriptOptions { Type = ParseTranscriptType(request?.Type), IncludeLlmRationale = request?.IncludeLlmRationale ?? true, RationalePromptHint = request?.RationalePromptHint, MaxLength = request?.MaxLength }; var transcript = await transcriptGenerator.GenerateTranscriptAsync(graph, options, ct).ConfigureAwait(false); return Results.Ok(new EvidenceTranscriptResponse { TranscriptId = transcript.Id, ThreadId = transcript.ThreadId, Type = transcript.TranscriptType.ToString(), TemplateVersion = transcript.TemplateVersion, LlmModel = transcript.LlmModel, Content = transcript.Content, AnchorCount = transcript.Anchors.Length, GeneratedAt = transcript.GeneratedAt }); } private static async Task GetEvidenceNodes( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService service, string artifactDigest, [FromQuery] string? kind, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } var filterKinds = string.IsNullOrWhiteSpace(kind) ? null : kind.Split(',') .Select(k => Enum.TryParse(k.Trim(), true, out var nk) ? nk : (EvidenceNodeKind?)null) .Where(k => k.HasValue) .Select(k => k!.Value) .ToList(); var options = new EvidenceThreadOptions { IncludeContent = true, NodeKinds = filterKinds }; var graph = await service.GetThreadGraphAsync( requestContext!.TenantId, artifactDigest, options, ct).ConfigureAwait(false); if (graph is null) { return Results.NotFound(new { error = "thread_not_found", artifactDigest }); } var nodes = graph.Nodes.Select(n => new EvidenceNodeResponse { Id = n.Id, Kind = n.Kind.ToString(), RefId = n.RefId, RefDigest = n.RefDigest, Title = n.Title, Summary = n.Summary, Confidence = n.Confidence, AnchorCount = n.Anchors.Length, CreatedAt = n.CreatedAt }).ToList(); return Results.Ok(new EvidenceNodeListResponse { ThreadId = graph.Thread.Id, ArtifactDigest = artifactDigest, Nodes = nodes, TotalCount = nodes.Count }); } private static async Task GetEvidenceLinks( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService service, string artifactDigest, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } var graph = await service.GetThreadGraphAsync( requestContext!.TenantId, artifactDigest, null, ct).ConfigureAwait(false); if (graph is null) { return Results.NotFound(new { error = "thread_not_found", artifactDigest }); } var links = graph.Links.Select(l => new EvidenceLinkResponse { Id = l.Id, SrcNodeId = l.SrcNodeId, DstNodeId = l.DstNodeId, Relation = l.Relation.ToString(), Weight = l.Weight, CreatedAt = l.CreatedAt }).ToList(); return Results.Ok(new EvidenceLinkListResponse { ThreadId = graph.Thread.Id, ArtifactDigest = artifactDigest, Links = links, TotalCount = links.Count }); } private static async Task CollectEvidence( HttpContext context, PlatformRequestContextResolver resolver, IEvidenceThreadService threadService, IEvidenceNodeCollector collector, string artifactDigest, [FromBody] EvidenceCollectionRequest? request, CancellationToken ct) { if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) { return failure!; } if (string.IsNullOrWhiteSpace(artifactDigest)) { return Results.BadRequest(new { error = "artifact_digest_required" }); } // Get or create the thread var thread = await threadService.GetOrCreateThreadAsync( requestContext!.TenantId, artifactDigest, artifactName: null, ct).ConfigureAwait(false); var options = new EvidenceCollectionOptions { BaseArtifactDigest = request?.BaseArtifactDigest, CollectSbomDiff = request?.CollectSbomDiff ?? true, CollectReachability = request?.CollectReachability ?? true, CollectVex = request?.CollectVex ?? true, CollectAttestations = request?.CollectAttestations ?? true }; var result = await collector.CollectAllAsync(thread.Id, artifactDigest, options, ct).ConfigureAwait(false); return Results.Ok(new EvidenceCollectionResponse { ThreadId = thread.Id, ArtifactDigest = artifactDigest, NodesCollected = result.Nodes.Count, LinksCreated = result.Links.Count, ErrorCount = result.Errors.Count, Errors = result.Errors.Select(e => new EvidenceCollectionErrorResponse { Source = e.Source, Message = e.Message, ExceptionType = e.ExceptionType }).ToList(), DurationMs = result.DurationMs }); } private static ThreadExportFormat ParseExportFormat(string? format) => format?.ToLowerInvariant() switch { "dsse" => ThreadExportFormat.Dsse, "json" => ThreadExportFormat.Json, "markdown" => ThreadExportFormat.Markdown, "pdf" => ThreadExportFormat.Pdf, _ => ThreadExportFormat.Dsse }; private static TranscriptType ParseTranscriptType(string? type) => type?.ToLowerInvariant() switch { "summary" => TranscriptType.Summary, "detailed" => TranscriptType.Detailed, "audit" => TranscriptType.Audit, _ => TranscriptType.Detailed }; private static bool TryResolveContext( HttpContext context, PlatformRequestContextResolver resolver, out PlatformRequestContext? requestContext, out IResult? failure) { if (resolver.TryResolve(context, out requestContext, out var error)) { failure = null; return true; } failure = Results.BadRequest(new { error = error ?? "tenant_missing" }); return false; } } #region Request/Response DTOs /// /// Response for evidence thread query. /// public sealed record EvidenceThreadResponse { public Guid ThreadId { get; init; } public string? TenantId { get; init; } public string? ArtifactDigest { get; init; } public string? ArtifactName { get; init; } public string? Status { get; init; } public string? Verdict { get; init; } public decimal? RiskScore { get; init; } public string? ReachabilityMode { get; init; } public int NodeCount { get; init; } public int LinkCount { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset UpdatedAt { get; init; } } /// /// Request for evidence export. /// public sealed record EvidenceExportRequest { public string? Format { get; init; } public bool? IncludeTranscript { get; init; } public bool? Sign { get; init; } public string? SigningKeyId { get; init; } } /// /// Response for evidence export. /// public sealed record EvidenceExportResponse { public Guid ThreadId { get; init; } public string? Format { get; init; } public string? ContentDigest { get; init; } public long SizeBytes { get; init; } public string? SigningKeyId { get; init; } public DateTimeOffset ExportedAt { get; init; } public long DurationMs { get; init; } public DsseEnvelopeResponse? Envelope { get; init; } } /// /// DSSE envelope response DTO. /// public sealed record DsseEnvelopeResponse { public string? PayloadType { get; init; } public string? Payload { get; init; } public string? PayloadDigest { get; init; } public List? Signatures { get; init; } } /// /// DSSE signature response DTO. /// public sealed record DsseSignatureResponse { public string? KeyId { get; init; } public string? Sig { get; init; } public string? Algorithm { get; init; } } /// /// Request for transcript generation. /// public sealed record EvidenceTranscriptRequest { public string? Type { get; init; } public bool? IncludeLlmRationale { get; init; } public string? RationalePromptHint { get; init; } public int? MaxLength { get; init; } } /// /// Response for transcript generation. /// public sealed record EvidenceTranscriptResponse { public Guid TranscriptId { get; init; } public Guid ThreadId { get; init; } public string? Type { get; init; } public string? TemplateVersion { get; init; } public string? LlmModel { get; init; } public string? Content { get; init; } public int AnchorCount { get; init; } public DateTimeOffset GeneratedAt { get; init; } } /// /// Response for evidence node query. /// public sealed record EvidenceNodeResponse { public Guid Id { get; init; } public string? Kind { get; init; } public string? RefId { get; init; } public string? RefDigest { get; init; } public string? Title { get; init; } public string? Summary { get; init; } public decimal? Confidence { get; init; } public int AnchorCount { get; init; } public DateTimeOffset CreatedAt { get; init; } } /// /// Response for evidence node list. /// public sealed record EvidenceNodeListResponse { public Guid ThreadId { get; init; } public string? ArtifactDigest { get; init; } public List? Nodes { get; init; } public int TotalCount { get; init; } } /// /// Response for evidence link query. /// public sealed record EvidenceLinkResponse { public Guid Id { get; init; } public Guid SrcNodeId { get; init; } public Guid DstNodeId { get; init; } public string? Relation { get; init; } public decimal? Weight { get; init; } public DateTimeOffset CreatedAt { get; init; } } /// /// Response for evidence link list. /// public sealed record EvidenceLinkListResponse { public Guid ThreadId { get; init; } public string? ArtifactDigest { get; init; } public List? Links { get; init; } public int TotalCount { get; init; } } /// /// Request for evidence collection. /// public sealed record EvidenceCollectionRequest { public string? BaseArtifactDigest { get; init; } public bool? CollectSbomDiff { get; init; } public bool? CollectReachability { get; init; } public bool? CollectVex { get; init; } public bool? CollectAttestations { get; init; } } /// /// Response for evidence collection. /// public sealed record EvidenceCollectionResponse { public Guid ThreadId { get; init; } public string? ArtifactDigest { get; init; } public int NodesCollected { get; init; } public int LinksCreated { get; init; } public int ErrorCount { get; init; } public List? Errors { get; init; } public long DurationMs { get; init; } } /// /// Error response for evidence collection. /// public sealed record EvidenceCollectionErrorResponse { public string? Source { get; init; } public string? Message { get; init; } public string? ExceptionType { get; init; } } #endregion