using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.JobEngine.Core.Domain; using StellaOps.JobEngine.Infrastructure.Repositories; using StellaOps.JobEngine.WebService.Contracts; using StellaOps.JobEngine.WebService.Services; using static StellaOps.Localization.T; namespace StellaOps.JobEngine.WebService.Endpoints; /// /// REST API endpoints for ledger operations. /// public static class LedgerEndpoints { /// /// Maps ledger endpoints to the route builder. /// public static RouteGroupBuilder MapLedgerEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/jobengine/ledger") .WithTags("Orchestrator Ledger") .RequireAuthorization(JobEnginePolicies.Read) .RequireTenant(); // Ledger entry operations group.MapGet(string.Empty, ListLedgerEntries) .WithName("Orchestrator_ListLedgerEntries") .WithDescription(_t("orchestrator.ledger.list_description")); group.MapGet("{ledgerId:guid}", GetLedgerEntry) .WithName("Orchestrator_GetLedgerEntry") .WithDescription(_t("orchestrator.ledger.get_description")); group.MapGet("run/{runId:guid}", GetByRunId) .WithName("Orchestrator_GetLedgerByRunId") .WithDescription(_t("orchestrator.ledger.get_by_run_description")); group.MapGet("source/{sourceId:guid}", GetBySource) .WithName("Orchestrator_GetLedgerBySource") .WithDescription(_t("orchestrator.ledger.get_by_source_description")); group.MapGet("latest", GetLatestEntry) .WithName("Orchestrator_GetLatestLedgerEntry") .WithDescription(_t("orchestrator.ledger.get_latest_description")); group.MapGet("sequence/{startSeq:long}/{endSeq:long}", GetBySequenceRange) .WithName("Orchestrator_GetLedgerBySequence") .WithDescription(_t("orchestrator.ledger.get_by_sequence_description")); // Summary and verification group.MapGet("summary", GetLedgerSummary) .WithName("Orchestrator_GetLedgerSummary") .WithDescription(_t("orchestrator.ledger.summary_description")); group.MapGet("verify", VerifyLedgerChain) .WithName("Orchestrator_VerifyLedgerChain") .WithDescription(_t("orchestrator.ledger.verify_chain_description")); // Export operations group.MapGet("exports", ListExports) .WithName("Orchestrator_ListLedgerExports") .WithDescription(_t("orchestrator.ledger.list_exports_description")); group.MapGet("exports/{exportId:guid}", GetExport) .WithName("Orchestrator_GetLedgerExport") .WithDescription(_t("orchestrator.ledger.get_export_description")); group.MapPost("exports", CreateExport) .WithName("Orchestrator_CreateLedgerExport") .WithDescription(_t("orchestrator.ledger.create_export_description")) .RequireAuthorization(JobEnginePolicies.ExportOperator); // Manifest operations group.MapGet("manifests", ListManifests) .WithName("Orchestrator_ListManifests") .WithDescription(_t("orchestrator.ledger.list_manifests_description")); group.MapGet("manifests/{manifestId:guid}", GetManifest) .WithName("Orchestrator_GetManifest") .WithDescription(_t("orchestrator.ledger.get_manifest_description")); group.MapGet("manifests/subject/{subjectId:guid}", GetManifestBySubject) .WithName("Orchestrator_GetManifestBySubject") .WithDescription(_t("orchestrator.ledger.get_manifest_by_subject_description")); group.MapGet("manifests/{manifestId:guid}/verify", VerifyManifest) .WithName("Orchestrator_VerifyManifest") .WithDescription(_t("orchestrator.ledger.verify_manifest_description")); return group; } private static async Task ListLedgerEntries( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, [FromQuery] string? runType = null, [FromQuery] Guid? sourceId = null, [FromQuery] string? finalStatus = null, [FromQuery] DateTimeOffset? startTime = null, [FromQuery] DateTimeOffset? endTime = null, [FromQuery] int? limit = null, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var effectiveLimit = EndpointHelpers.GetLimit(limit); var offset = EndpointHelpers.ParseCursorOffset(cursor); RunStatus? parsedStatus = null; if (!string.IsNullOrEmpty(finalStatus) && Enum.TryParse(finalStatus, true, out var rs)) { parsedStatus = rs; } var entries = await repository.ListAsync( tenantId, runType, sourceId, parsedStatus, startTime, endTime, effectiveLimit, offset, cancellationToken).ConfigureAwait(false); var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList(); var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count); return Results.Ok(new LedgerEntryListResponse(responses, nextCursor)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetLedgerEntry( HttpContext context, [FromRoute] Guid ledgerId, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var entry = await repository.GetByIdAsync(tenantId, ledgerId, cancellationToken).ConfigureAwait(false); if (entry is null) { return Results.NotFound(); } return Results.Ok(LedgerEntryResponse.FromDomain(entry)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetByRunId( HttpContext context, [FromRoute] Guid runId, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var entry = await repository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false); if (entry is null) { return Results.NotFound(); } return Results.Ok(LedgerEntryResponse.FromDomain(entry)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetBySource( HttpContext context, [FromRoute] Guid sourceId, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, [FromQuery] int? limit = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var effectiveLimit = EndpointHelpers.GetLimit(limit); var entries = await repository.GetBySourceAsync( tenantId, sourceId, effectiveLimit, cancellationToken).ConfigureAwait(false); var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList(); return Results.Ok(new LedgerEntryListResponse(responses, null)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetLatestEntry( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var entry = await repository.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); if (entry is null) { return Results.NotFound(); } return Results.Ok(LedgerEntryResponse.FromDomain(entry)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetBySequenceRange( HttpContext context, [FromRoute] long startSeq, [FromRoute] long endSeq, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); if (startSeq < 1 || endSeq < startSeq) { return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_sequence_range") }); } var entries = await repository.GetBySequenceRangeAsync( tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false); var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList(); return Results.Ok(new LedgerEntryListResponse(responses, null)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetLedgerSummary( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, [FromQuery] DateTimeOffset? since = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var summary = await repository.GetSummaryAsync(tenantId, since, cancellationToken).ConfigureAwait(false); return Results.Ok(LedgerSummaryResponse.FromDomain(summary)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task VerifyLedgerChain( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerRepository repository, [FromQuery] long? startSeq = null, [FromQuery] long? endSeq = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var result = await repository.VerifyChainAsync(tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false); Infrastructure.JobEngineMetrics.LedgerChainVerified(tenantId, result.IsValid); return Results.Ok(ChainVerificationResponse.FromDomain(result)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task ListExports( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerExportRepository repository, [FromQuery] string? status = null, [FromQuery] int? limit = null, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var effectiveLimit = EndpointHelpers.GetLimit(limit); var offset = EndpointHelpers.ParseCursorOffset(cursor); LedgerExportStatus? parsedStatus = null; if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var es)) { parsedStatus = es; } var exports = await repository.ListAsync( tenantId, parsedStatus, effectiveLimit, offset, cancellationToken).ConfigureAwait(false); var responses = exports.Select(LedgerExportResponse.FromDomain).ToList(); var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count); return Results.Ok(new LedgerExportListResponse(responses, nextCursor)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetExport( HttpContext context, [FromRoute] Guid exportId, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerExportRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var export = await repository.GetByIdAsync(tenantId, exportId, cancellationToken).ConfigureAwait(false); if (export is null) { return Results.NotFound(); } return Results.Ok(LedgerExportResponse.FromDomain(export)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task CreateExport( HttpContext context, [FromBody] CreateLedgerExportRequest request, [FromServices] TenantResolver tenantResolver, [FromServices] ILedgerExportRepository repository, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var actorId = context.User?.Identity?.Name ?? "system"; var now = timeProvider.GetUtcNow(); // Validate format var validFormats = new[] { "json", "ndjson", "csv" }; if (!validFormats.Contains(request.Format?.ToLowerInvariant())) { return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_format", string.Join(", ", validFormats)) }); } // Validate time range if (request.StartTime.HasValue && request.EndTime.HasValue && request.StartTime > request.EndTime) { return Results.BadRequest(new { error = _t("orchestrator.ledger.error.start_before_end") }); } var export = LedgerExport.CreateRequest( tenantId: tenantId, format: request.Format!, requestedBy: actorId, requestedAt: now, startTime: request.StartTime, endTime: request.EndTime, runTypeFilter: request.RunTypeFilter, sourceIdFilter: request.SourceIdFilter); await repository.CreateAsync(export, cancellationToken).ConfigureAwait(false); return Results.Created($"/api/v1/jobengine/ledger/exports/{export.ExportId}", LedgerExportResponse.FromDomain(export)); } catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task ListManifests( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] IManifestRepository repository, [FromQuery] string? provenanceType = null, [FromQuery] int? limit = null, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var effectiveLimit = EndpointHelpers.GetLimit(limit); var offset = EndpointHelpers.ParseCursorOffset(cursor); ProvenanceType? parsedType = null; if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse(provenanceType, true, out var pt)) { parsedType = pt; } var manifests = await repository.ListAsync( tenantId, parsedType, effectiveLimit, offset, cancellationToken).ConfigureAwait(false); var responses = manifests.Select(ManifestResponse.FromDomain).ToList(); var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count); return Results.Ok(new ManifestListResponse(responses, nextCursor)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetManifest( HttpContext context, [FromRoute] Guid manifestId, [FromServices] TenantResolver tenantResolver, [FromServices] IManifestRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false); if (manifest is null) { return Results.NotFound(); } return Results.Ok(ManifestDetailResponse.FromDomain(manifest)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetManifestBySubject( HttpContext context, [FromRoute] Guid subjectId, [FromServices] TenantResolver tenantResolver, [FromServices] IManifestRepository repository, [FromQuery] string? provenanceType = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); ProvenanceType parsedType = ProvenanceType.Run; if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse(provenanceType, true, out var pt)) { parsedType = pt; } var manifest = await repository.GetBySubjectAsync(tenantId, parsedType, subjectId, cancellationToken).ConfigureAwait(false); if (manifest is null) { return Results.NotFound(); } return Results.Ok(ManifestDetailResponse.FromDomain(manifest)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task VerifyManifest( HttpContext context, [FromRoute] Guid manifestId, [FromServices] TenantResolver tenantResolver, [FromServices] IManifestRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false); if (manifest is null) { return Results.NotFound(); } var payloadValid = manifest.VerifyPayloadIntegrity(); string? validationError = null; if (!payloadValid) { validationError = _t("orchestrator.ledger.error.payload_digest_mismatch"); } else if (manifest.IsExpired) { validationError = _t("orchestrator.ledger.error.manifest_expired"); } Infrastructure.JobEngineMetrics.ManifestVerified(tenantId, payloadValid && !manifest.IsExpired); return Results.Ok(new ManifestVerificationResponse( ManifestId: manifestId, PayloadIntegrityValid: payloadValid, IsExpired: manifest.IsExpired, IsSigned: manifest.IsSigned, ValidationError: validationError)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } }