using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace StellaOps.Provcache.Api; /// /// Marker class for logging in Provcache API endpoints. /// public sealed class ProvcacheApiEndpoints; /// /// Extension methods for mapping Provcache API endpoints. /// public static partial class ProvcacheEndpointExtensions { /// /// Maps Provcache API endpoints to the specified route builder. /// /// The endpoint route builder. /// The route prefix (default: "/v1/provcache"). /// A route group builder for further customization. public static RouteGroupBuilder MapProvcacheEndpoints( this IEndpointRouteBuilder endpoints, string prefix = "/v1/provcache") { var group = endpoints.MapGroup(prefix) .WithTags("Provcache") .WithOpenApi(); // GET /v1/provcache/{veriKey} group.MapGet("/{veriKey}", GetByVeriKey) .WithName("GetProvcacheEntry") .WithSummary("Get a cached decision by VeriKey") .WithDescription("Retrieves a cached evaluation decision by its VeriKey. Returns 200 if found, 204 if not cached, 410 if expired.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status500InternalServerError); // POST /v1/provcache group.MapPost("/", CreateOrUpdate) .WithName("CreateOrUpdateProvcacheEntry") .WithSummary("Store a decision in the cache (idempotent)") .WithDescription("Stores or updates a cached evaluation decision. This operation is idempotent - storing the same VeriKey multiple times is safe.") .Accepts("application/json") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status500InternalServerError); // POST /v1/provcache/invalidate group.MapPost("/invalidate", Invalidate) .WithName("InvalidateProvcacheEntries") .WithSummary("Invalidate cache entries by key or pattern") .WithDescription("Invalidates one or more cache entries. Can invalidate by exact VeriKey, policy hash, signer set hash, feed epoch, or pattern.") .Accepts("application/json") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status500InternalServerError); // GET /v1/provcache/metrics group.MapGet("/metrics", GetMetrics) .WithName("GetProvcacheMetrics") .WithSummary("Get cache performance metrics") .WithDescription("Returns current cache metrics including hit rate, miss rate, latency percentiles, and entry counts.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status500InternalServerError); // GET /v1/provcache/{veriKey}/manifest group.MapGet("/{veriKey}/manifest", GetInputManifest) .WithName("GetInputManifest") .WithSummary("Get input manifest for VeriKey components") .WithDescription("Returns detailed information about the inputs (SBOM, VEX, policy, signers) that formed a cached decision. Use for transparency and debugging.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status500InternalServerError); // Map evidence paging endpoints under /proofs var proofsGroup = endpoints.MapGroup($"{prefix}/proofs") .WithTags("Provcache Evidence") .WithOpenApi(); // GET /v1/provcache/proofs/{proofRoot} proofsGroup.MapGet("/{proofRoot}", GetEvidenceChunks) .WithName("GetProofEvidence") .WithSummary("Get evidence chunks by proof root") .WithDescription("Retrieves evidence chunks for a proof root with pagination support. Use cursor parameter for subsequent pages.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status500InternalServerError); // GET /v1/provcache/proofs/{proofRoot}/manifest proofsGroup.MapGet("/{proofRoot}/manifest", GetProofManifest) .WithName("GetProofManifest") .WithSummary("Get chunk manifest (metadata without data)") .WithDescription("Retrieves the chunk manifest for lazy evidence fetching. Contains hashes and sizes but no blob data.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status500InternalServerError); // GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex} proofsGroup.MapGet("/{proofRoot}/chunks/{chunkIndex:int}", GetSingleChunk) .WithName("GetProofChunk") .WithSummary("Get a single chunk by index") .WithDescription("Retrieves a specific chunk by its index within the proof.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status500InternalServerError); // POST /v1/provcache/proofs/{proofRoot}/verify proofsGroup.MapPost("/{proofRoot}/verify", VerifyProof) .WithName("VerifyProof") .WithSummary("Verify Merkle tree integrity") .WithDescription("Verifies all chunk hashes and the Merkle tree for the proof root.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status500InternalServerError); return group; } /// /// GET /v1/provcache/{veriKey} /// private static async Task GetByVeriKey( string veriKey, bool? bypassCache, IProvcacheService provcacheService, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/{VeriKey}", veriKey); try { var result = await provcacheService.GetAsync(veriKey, bypassCache ?? false, cancellationToken); return result.Status switch { ProvcacheResultStatus.CacheHit => Results.Ok(new ProvcacheGetResponse { VeriKey = result.Entry!.VeriKey, Entry = result.Entry, Source = result.Source, ElapsedMs = result.ElapsedMs, Status = "hit" }), ProvcacheResultStatus.Bypassed => Results.Ok(new ProvcacheGetResponse { VeriKey = veriKey, Entry = null, Source = null, ElapsedMs = result.ElapsedMs, Status = "bypassed" }), ProvcacheResultStatus.Expired => Results.StatusCode(StatusCodes.Status410Gone), _ => Results.NoContent() }; } catch (Exception ex) { logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Cache lookup failed"); } } /// /// POST /v1/provcache /// private static async Task CreateOrUpdate( ProvcacheCreateRequest request, IProvcacheService provcacheService, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("POST /v1/provcache for VeriKey {VeriKey}", request.Entry?.VeriKey); if (request.Entry is null) { return Results.Problem( detail: "Request body must contain a valid entry", statusCode: StatusCodes.Status400BadRequest, title: "Invalid request"); } try { var success = await provcacheService.SetAsync(request.Entry, cancellationToken); if (!success) { return Results.Problem( detail: "Failed to store cache entry", statusCode: StatusCodes.Status500InternalServerError, title: "Cache write failed"); } return Results.Created($"/v1/provcache/{request.Entry.VeriKey}", new ProvcacheCreateResponse { VeriKey = request.Entry.VeriKey, Success = true, ExpiresAt = request.Entry.ExpiresAt }); } catch (Exception ex) { logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", request.Entry?.VeriKey); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Cache write failed"); } } /// /// POST /v1/provcache/invalidate /// private static async Task Invalidate( ProvcacheInvalidateRequest request, IProvcacheService provcacheService, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("POST /v1/provcache/invalidate type={Type} value={Value}", request.Type, request.Value); try { // If single VeriKey invalidation (Type is null = single VeriKey mode) if (request.Type is null) { var success = await provcacheService.InvalidateAsync(request.Value, request.Reason, cancellationToken); return Results.Ok(new ProvcacheInvalidateResponse { EntriesAffected = success ? 1 : 0, Type = "verikey", Value = request.Value, Reason = request.Reason }); } // Bulk invalidation var invalidationRequest = new InvalidationRequest { Type = request.Type ?? InvalidationType.Pattern, Value = request.Value, Reason = request.Reason, Actor = request.Actor }; var result = await provcacheService.InvalidateByAsync(invalidationRequest, cancellationToken); return Results.Ok(new ProvcacheInvalidateResponse { EntriesAffected = result.EntriesAffected, Type = request.Type?.ToString() ?? "pattern", Value = request.Value, Reason = request.Reason }); } catch (Exception ex) { logger.LogError(ex, "Error invalidating cache entries"); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Cache invalidation failed"); } } /// /// GET /v1/provcache/metrics /// private static async Task GetMetrics( IProvcacheService provcacheService, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/metrics"); try { var metrics = await provcacheService.GetMetricsAsync(cancellationToken); var hitRate = metrics.TotalRequests > 0 ? (double)metrics.TotalHits / metrics.TotalRequests : 0; return Results.Ok(new ProvcacheMetricsResponse { TotalRequests = metrics.TotalRequests, TotalHits = metrics.TotalHits, TotalMisses = metrics.TotalMisses, TotalInvalidations = metrics.TotalInvalidations, HitRate = hitRate, CurrentEntryCount = metrics.CurrentEntryCount, AvgLatencyMs = metrics.AvgLatencyMs, P99LatencyMs = metrics.P99LatencyMs, ValkeyCacheHealthy = metrics.ValkeyCacheHealthy, PostgresRepositoryHealthy = metrics.PostgresRepositoryHealthy, CollectedAt = metrics.CollectedAt }); } catch (Exception ex) { logger.LogError(ex, "Error getting cache metrics"); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Metrics retrieval failed"); } } /// /// GET /v1/provcache/{veriKey}/manifest /// private static async Task GetInputManifest( string veriKey, IProvcacheService provcacheService, TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/{VeriKey}/manifest", veriKey); try { // First get the entry to verify it exists var result = await provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken); if (result.Status == ProvcacheResultStatus.CacheMiss) { return Results.NotFound(new ProblemDetails { Title = "Entry not found", Detail = $"No cache entry found for VeriKey: {veriKey}", Status = StatusCodes.Status404NotFound }); } if (result.Status == ProvcacheResultStatus.Expired) { return Results.NotFound(new ProblemDetails { Title = "Entry expired", Detail = $"Cache entry for VeriKey '{veriKey}' has expired", Status = StatusCodes.Status404NotFound }); } var entry = result.Entry; if (entry is null) { return Results.NotFound(new ProblemDetails { Title = "Entry not found", Detail = $"No cache entry found for VeriKey: {veriKey}", Status = StatusCodes.Status404NotFound }); } // Build the input manifest from the entry metadata // In a full implementation, we'd resolve these hashes to more detailed metadata // from their respective stores (SBOM store, VEX store, policy registry, etc.) var manifest = BuildInputManifest(entry, timeProvider); return Results.Ok(manifest); } catch (Exception ex) { logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Manifest retrieval failed"); } } /// /// Builds an InputManifestResponse from a ProvcacheEntry. /// private static InputManifestResponse BuildInputManifest(ProvcacheEntry entry, TimeProvider timeProvider) { // Build input manifest from the entry and its embedded DecisionDigest // The DecisionDigest contains the VeriKey components as hashes var decision = entry.Decision; return new InputManifestResponse { VeriKey = entry.VeriKey, SourceArtifact = new SourceArtifactInfo { // VeriKey includes source hash as first component Digest = entry.VeriKey, }, Sbom = new SbomInfoDto { // SBOM hash is embedded in VeriKey computation // In a full implementation, we'd resolve this from the SBOM store Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition }, Vex = new VexInfoDto { // VEX hash set is embedded in VeriKey computation HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder StatementCount = 0, // Would be resolved from VEX store }, Policy = new PolicyInfoDto { Hash = entry.PolicyHash, }, Signers = new SignerInfoDto { SetHash = entry.SignerSetHash, SignerCount = 0, // Would be resolved from signer registry }, TimeWindow = new TimeWindowInfoDto { Bucket = entry.FeedEpoch, StartsAt = entry.CreatedAt, EndsAt = entry.ExpiresAt, }, GeneratedAt = timeProvider.GetUtcNow(), }; } } /// /// Placeholder for problem details when ASP.NET Core's ProblemDetails isn't available. /// internal sealed class ProblemDetails { public string? Type { get; set; } public string? Title { get; set; } public int? Status { get; set; } public string? Detail { get; set; } public string? Instance { get; set; } } /// /// Marker class for logging in Proofs API endpoints. /// public sealed class ProofsApiEndpoints; partial class ProvcacheEndpointExtensions { private const int DefaultPageSize = 10; private const int MaxPageSize = 100; /// /// GET /v1/provcache/proofs/{proofRoot} /// private static async Task GetEvidenceChunks( string proofRoot, int? offset, int? limit, bool? includeData, [FromServices] IEvidenceChunkRepository chunkRepository, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot} offset={Offset} limit={Limit}", proofRoot, offset, limit); try { var startIndex = offset ?? 0; var pageSize = Math.Min(limit ?? DefaultPageSize, MaxPageSize); // Get manifest for total count var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken); if (manifest is null) { return Results.NotFound(); } // Get chunk range var chunks = await chunkRepository.GetChunkRangeAsync(proofRoot, startIndex, pageSize, cancellationToken); var chunkResponses = chunks.Select(c => new ProofChunkResponse { ChunkId = c.ChunkId, Index = c.ChunkIndex, Hash = c.ChunkHash, Size = c.BlobSize, ContentType = c.ContentType, Data = includeData == true ? Convert.ToBase64String(c.Blob) : null }).ToList(); var hasMore = startIndex + chunks.Count < manifest.TotalChunks; var nextCursor = hasMore ? (startIndex + pageSize).ToString() : null; return Results.Ok(new ProofEvidenceResponse { ProofRoot = proofRoot, TotalChunks = manifest.TotalChunks, TotalSize = manifest.TotalSize, Chunks = chunkResponses, NextCursor = nextCursor, HasMore = hasMore }); } catch (Exception ex) { logger.LogError(ex, "Error getting evidence chunks for proof root {ProofRoot}", proofRoot); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Evidence retrieval failed"); } } /// /// GET /v1/provcache/proofs/{proofRoot}/manifest /// private static async Task GetProofManifest( string proofRoot, [FromServices] IEvidenceChunkRepository chunkRepository, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/manifest", proofRoot); try { var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken); if (manifest is null) { return Results.NotFound(); } var chunkMetadata = manifest.Chunks.Select(c => new ChunkMetadataResponse { ChunkId = c.ChunkId, Index = c.Index, Hash = c.Hash, Size = c.Size, ContentType = c.ContentType }).ToList(); return Results.Ok(new ProofManifestResponse { ProofRoot = proofRoot, TotalChunks = manifest.TotalChunks, TotalSize = manifest.TotalSize, Chunks = chunkMetadata, GeneratedAt = manifest.GeneratedAt }); } catch (Exception ex) { logger.LogError(ex, "Error getting manifest for proof root {ProofRoot}", proofRoot); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Manifest retrieval failed"); } } /// /// GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex} /// private static async Task GetSingleChunk( string proofRoot, int chunkIndex, [FromServices] IEvidenceChunkRepository chunkRepository, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/chunks/{ChunkIndex}", proofRoot, chunkIndex); try { var chunk = await chunkRepository.GetChunkAsync(proofRoot, chunkIndex, cancellationToken); if (chunk is null) { return Results.NotFound(); } return Results.Ok(new ProofChunkResponse { ChunkId = chunk.ChunkId, Index = chunk.ChunkIndex, Hash = chunk.ChunkHash, Size = chunk.BlobSize, ContentType = chunk.ContentType, Data = Convert.ToBase64String(chunk.Blob) }); } catch (Exception ex) { logger.LogError(ex, "Error getting chunk {ChunkIndex} for proof root {ProofRoot}", chunkIndex, proofRoot); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Chunk retrieval failed"); } } /// /// POST /v1/provcache/proofs/{proofRoot}/verify /// private static async Task VerifyProof( string proofRoot, [FromServices] IEvidenceChunkRepository chunkRepository, [FromServices] IEvidenceChunker chunker, ILogger logger, CancellationToken cancellationToken) { logger.LogDebug("POST /v1/provcache/proofs/{ProofRoot}/verify", proofRoot); try { var chunks = await chunkRepository.GetChunksAsync(proofRoot, cancellationToken); if (chunks.Count == 0) { return Results.NotFound(); } var chunkResults = new List(); var allValid = true; foreach (var chunk in chunks) { var isValid = chunker.VerifyChunk(chunk); var computedHash = isValid ? chunk.ChunkHash : ComputeChunkHash(chunk.Blob); chunkResults.Add(new ChunkVerificationResult { Index = chunk.ChunkIndex, IsValid = isValid, ExpectedHash = chunk.ChunkHash, ComputedHash = isValid ? null : computedHash }); if (!isValid) { allValid = false; } } // Verify Merkle root var chunkHashes = chunks.Select(c => c.ChunkHash).ToList(); var computedRoot = chunker.ComputeMerkleRoot(chunkHashes); var rootMatches = string.Equals(computedRoot, proofRoot, StringComparison.OrdinalIgnoreCase); return Results.Ok(new ProofVerificationResponse { ProofRoot = proofRoot, IsValid = allValid && rootMatches, ChunkResults = chunkResults, Error = !rootMatches ? $"Merkle root mismatch. Expected: {proofRoot}, Computed: {computedRoot}" : null }); } catch (Exception ex) { logger.LogError(ex, "Error verifying proof root {ProofRoot}", proofRoot); return Results.Problem( detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Proof verification failed"); } } private static string ComputeChunkHash(byte[] data) { var hash = System.Security.Cryptography.SHA256.HashData(data); return $"sha256:{Convert.ToHexStringLower(hash)}"; } }