//
// 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.Auth.ServerIntegration.Tenancy;
using StellaOps.Platform.WebService.Constants;
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")
.RequireAuthorization(PlatformPolicies.ContextRead)
.RequireTenant();
// 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, including node count, link count, verdict, and risk score.")
.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. Supports DSSE, JSON, Markdown, and PDF formats. The envelope is optionally signed with the specified key.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.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 in summary, detailed, or audit format. May invoke an LLM for rationale generation when enabled.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.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, optionally filtered by node kind (e.g., sbom, scan, attestation). Returns node summaries, confidence scores, and anchor counts.")
.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 directed evidence links in the thread, describing provenance and dependency relationships between evidence nodes.")
.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: SBOM diff, reachability graph, VEX advisories, and attestations. Returns the count of nodes and links created, plus any collection errors.")
.RequireAuthorization(PlatformPolicies.ContextWrite)
.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