// ----------------------------------------------------------------------------- // ExportEndpoints.cs // Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle // Tasks: T020, T021 // Description: Minimal API endpoints for evidence bundle export. // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; namespace StellaOps.EvidenceLocker.Api; /// /// Minimal API endpoints for evidence bundle export. /// public static class ExportEndpoints { /// /// Maps export endpoints to the application. /// public static void MapExportEndpoints(this WebApplication app) { var group = app.MapGroup("/api/v1/bundles") .WithTags("Export") .RequireTenant(); // POST /api/v1/bundles/{bundleId}/export group.MapPost("/{bundleId}/export", TriggerExportAsync) .WithName("TriggerExport") .WithSummary("Trigger an async evidence bundle export") .WithDescription("Enqueues an asynchronous export job for a specific evidence bundle. Returns 202 Accepted with the export job ID and a status polling URL. Returns 404 if the bundle ID is not registered.") .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator); // GET /api/v1/bundles/{bundleId}/export/{exportId} group.MapGet("/{bundleId}/export/{exportId}", GetExportStatusAsync) .WithName("GetExportStatus") .WithSummary("Get export status or download exported bundle") .WithDescription("Returns the current status of an evidence bundle export job. Returns 200 with the export manifest when complete, or 202 if the export is still in progress. Returns 404 if the export ID is not found.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer); // GET /api/v1/bundles/{bundleId}/export/{exportId}/download group.MapGet("/{bundleId}/export/{exportId}/download", DownloadExportAsync) .WithName("DownloadExport") .WithSummary("Download the exported bundle") .WithDescription("Streams the completed evidence bundle as a gzip-compressed archive. Returns 409 Conflict if the export is still in progress. Returns 404 if the export ID is not found.") .Produces(StatusCodes.Status200OK, contentType: "application/gzip") .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer); } private static async Task TriggerExportAsync( string bundleId, [FromBody] ExportTriggerRequest request, [FromServices] IExportJobService exportJobService, CancellationToken cancellationToken) { var result = await exportJobService.CreateExportJobAsync(bundleId, request, cancellationToken); if (result.IsNotFound) { return Results.NotFound(new { message = $"Bundle '{bundleId}' not found" }); } return Results.Accepted( $"/api/v1/bundles/{bundleId}/export/{result.ExportId}", new ExportTriggerResponse { ExportId = result.ExportId, Status = result.Status, EstimatedSize = result.EstimatedSize, StatusUrl = $"/api/v1/bundles/{bundleId}/export/{result.ExportId}" }); } private static async Task GetExportStatusAsync( string bundleId, string exportId, [FromServices] IExportJobService exportJobService, CancellationToken cancellationToken) { var result = await exportJobService.GetExportStatusAsync(bundleId, exportId, cancellationToken); if (result is null) { return Results.NotFound(new { message = $"Export '{exportId}' not found" }); } if (result.Status == ExportJobStatusEnum.Ready) { return Results.Ok(new ExportStatusResponse { ExportId = result.ExportId, Status = result.Status.ToString().ToLowerInvariant(), DownloadUrl = $"/api/v1/bundles/{bundleId}/export/{exportId}/download", FileSize = result.FileSize, CompletedAt = result.CompletedAt }); } return Results.Accepted(value: new ExportStatusResponse { ExportId = result.ExportId, Status = result.Status.ToString().ToLowerInvariant(), Progress = result.Progress, EstimatedTimeRemaining = result.EstimatedTimeRemaining }); } private static async Task DownloadExportAsync( string bundleId, string exportId, [FromServices] IExportJobService exportJobService, CancellationToken cancellationToken) { var result = await exportJobService.GetExportFileAsync(bundleId, exportId, cancellationToken); if (result is null) { return Results.NotFound(new { message = $"Export '{exportId}' not found" }); } if (result.Status != ExportJobStatusEnum.Ready) { return Results.Conflict(new { message = "Export is not ready for download", status = result.Status.ToString().ToLowerInvariant() }); } return Results.File( result.FileStream!, contentType: "application/gzip", fileDownloadName: result.FileName); } } /// /// Request to trigger an evidence bundle export. /// public sealed record ExportTriggerRequest { /// /// Export format (default: tar.gz). /// public string Format { get; init; } = "tar.gz"; /// /// Compression type (gzip, brotli). /// public string Compression { get; init; } = "gzip"; /// /// Compression level (1-9). /// public int CompressionLevel { get; init; } = 6; /// /// Whether to include Rekor transparency proofs. /// public bool IncludeRekorProofs { get; init; } = true; /// /// Whether to include per-layer SBOMs. /// public bool IncludeLayerSboms { get; init; } = true; } /// /// Response after triggering an export. /// public sealed record ExportTriggerResponse { /// /// Unique export job ID. /// public required string ExportId { get; init; } /// /// Current export status. /// public required string Status { get; init; } /// /// Estimated final bundle size in bytes. /// public long? EstimatedSize { get; init; } /// /// URL to poll for status updates. /// public required string StatusUrl { get; init; } } /// /// Response for export status queries. /// public sealed record ExportStatusResponse { /// /// Export job ID. /// public required string ExportId { get; init; } /// /// Current status: pending, processing, ready, failed. /// public required string Status { get; init; } /// /// Progress percentage (0-100) when processing. /// public int? Progress { get; init; } /// /// Estimated time remaining when processing. /// public string? EstimatedTimeRemaining { get; init; } /// /// Download URL when ready. /// public string? DownloadUrl { get; init; } /// /// Final file size in bytes when ready. /// public long? FileSize { get; init; } /// /// Completion timestamp when ready. /// public DateTimeOffset? CompletedAt { get; init; } }