using System.Linq; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; using StellaOps.Auth.Abstractions; using StellaOps.Scanner.Core.Configuration; using StellaOps.Scanner.WebService.Constants; using StellaOps.Scanner.WebService.Infrastructure; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Endpoints; internal static class OfflineKitEndpoints { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; public static void MapOfflineKitEndpoints(this IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints); var group = endpoints .MapGroup("/api/offline-kit") .WithTags("Offline Kit"); // Sprint 026: OFFLINE-012 - Legacy v1 alias for backward compatibility var v1Group = endpoints .MapGroup("/api/v1/offline-kit") .WithTags("Offline Kit"); MapEndpointsToGroup(group, isLegacy: false); MapEndpointsToGroup(v1Group, isLegacy: true); } private static void MapEndpointsToGroup(RouteGroupBuilder group, bool isLegacy) { var suffix = isLegacy ? ".v1" : ""; group.MapPost("/import", HandleImportAsync) .WithName($"scanner.offline-kit.import{suffix}") .RequireAuthorization(ScannerPolicies.OfflineKitImport) .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status422UnprocessableEntity); group.MapGet("/status", HandleStatusAsync) .WithName($"scanner.offline-kit.status{suffix}") .RequireAuthorization(ScannerPolicies.OfflineKitStatusRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); // Sprint 026: OFFLINE-011 - Manifest retrieval group.MapGet("/manifest", HandleGetManifestAsync) .WithName($"scanner.offline-kit.manifest{suffix}") .RequireAuthorization(ScannerPolicies.OfflineKitManifestRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); // Sprint 026: OFFLINE-011 - Bundle validation group.MapPost("/validate", HandleValidateAsync) .WithName($"scanner.offline-kit.validate{suffix}") .RequireAuthorization(ScannerPolicies.OfflineKitValidate) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); } private static async Task HandleImportAsync( HttpContext context, HttpRequest request, IOptionsMonitor offlineKitOptions, OfflineKitImportService importService, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(offlineKitOptions); ArgumentNullException.ThrowIfNull(importService); if (!offlineKitOptions.CurrentValue.Enabled) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Offline kit import is not enabled", StatusCodes.Status404NotFound); } if (!request.HasFormContentType) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offline kit import request", StatusCodes.Status400BadRequest, detail: "Request must be multipart/form-data."); } var form = await request.ReadFormAsync(cancellationToken).ConfigureAwait(false); var metadataJson = form["metadata"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(metadataJson)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offline kit import request", StatusCodes.Status400BadRequest, detail: "Missing 'metadata' form field."); } OfflineKitImportMetadata? metadata; try { metadata = JsonSerializer.Deserialize(metadataJson, JsonOptions); } catch (JsonException ex) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offline kit import request", StatusCodes.Status400BadRequest, detail: $"Failed to parse metadata JSON: {ex.Message}"); } if (metadata is null) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offline kit import request", StatusCodes.Status400BadRequest, detail: "Metadata payload is empty."); } var bundle = form.Files.GetFile("bundle"); if (bundle is null) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid offline kit import request", StatusCodes.Status400BadRequest, detail: "Missing 'bundle' file upload."); } var manifest = form.Files.GetFile("manifest"); var bundleSignature = form.Files.GetFile("bundleSignature"); var manifestSignature = form.Files.GetFile("manifestSignature"); var tenantId = ResolveTenant(context); var actor = ResolveActor(context); try { var response = await importService.ImportAsync( new OfflineKitImportRequest( tenantId, actor, metadata, bundle, manifest, bundleSignature, manifestSignature), cancellationToken).ConfigureAwait(false); return Results.Accepted("/api/offline-kit/status", response); } catch (OfflineKitImportException ex) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Offline kit import failed", ex.StatusCode, detail: ex.Message, extensions: new Dictionary { ["reason_code"] = ex.ReasonCode, ["notes"] = ex.Notes }); } } private static async Task HandleStatusAsync( HttpContext context, IOptionsMonitor offlineKitOptions, OfflineKitStateStore stateStore, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(offlineKitOptions); ArgumentNullException.ThrowIfNull(stateStore); if (!offlineKitOptions.CurrentValue.Enabled) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Offline kit status is not enabled", StatusCodes.Status404NotFound); } var tenantId = ResolveTenant(context); var status = await stateStore.LoadStatusAsync(tenantId, cancellationToken).ConfigureAwait(false); return status is null ? Results.NoContent() : Results.Ok(status); } private static string ResolveTenant(HttpContext context) { var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant); if (!string.IsNullOrWhiteSpace(tenant)) { return tenant.Trim(); } if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant)) { var headerValue = headerTenant.ToString(); if (!string.IsNullOrWhiteSpace(headerValue)) { return headerValue.Trim(); } } return "default"; } private static string ResolveActor(HttpContext context) { var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject); if (!string.IsNullOrWhiteSpace(subject)) { return subject.Trim(); } var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId); if (!string.IsNullOrWhiteSpace(clientId)) { return clientId.Trim(); } return "anonymous"; } // Sprint 026: OFFLINE-011 - Manifest retrieval handler private static async Task HandleGetManifestAsync( HttpContext context, IOptionsMonitor offlineKitOptions, OfflineKitManifestService manifestService, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(offlineKitOptions); ArgumentNullException.ThrowIfNull(manifestService); if (!offlineKitOptions.CurrentValue.Enabled) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Offline kit is not enabled", StatusCodes.Status404NotFound); } var tenantId = ResolveTenant(context); var manifest = await manifestService.GetManifestAsync(tenantId, cancellationToken).ConfigureAwait(false); return manifest is null ? Results.NoContent() : Results.Ok(manifest); } // Sprint 026: OFFLINE-011 - Bundle validation handler private static async Task HandleValidateAsync( HttpContext context, HttpRequest request, IOptionsMonitor offlineKitOptions, OfflineKitManifestService manifestService, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(offlineKitOptions); ArgumentNullException.ThrowIfNull(manifestService); if (!offlineKitOptions.CurrentValue.Enabled) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Offline kit validation is not enabled", StatusCodes.Status404NotFound); } OfflineKitValidationRequest? validationRequest; try { validationRequest = await request.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid validation request", StatusCodes.Status400BadRequest, detail: $"Failed to parse request JSON: {ex.Message}"); } if (validationRequest is null || string.IsNullOrWhiteSpace(validationRequest.ManifestJson)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid validation request", StatusCodes.Status400BadRequest, detail: "Request body with manifestJson is required."); } var result = manifestService.ValidateManifest( validationRequest.ManifestJson, validationRequest.Signature, validationRequest.VerifyAssets); return Results.Ok(result); } }