// Copyright (c) StellaOps. All rights reserved. // Licensed under BUSL-1.1. See LICENSE in the project root. // Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-003) // Task: Add /api/v1/setup/* endpoints with auth policies, request validation, and Problem+JSON errors using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Platform.WebService.Constants; using StellaOps.Platform.WebService.Contracts; using StellaOps.Platform.WebService.Options; using StellaOps.Platform.WebService.Services; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Platform.WebService.Endpoints; /// /// Setup wizard API endpoints aligned to docs/setup/setup-wizard-ux.md. /// All endpoints are AllowAnonymous because during initial setup the Authority /// service is not running. When setup is already complete, the handlers /// enforce auth via TryResolveContext before proceeding. /// public static class SetupEndpoints { public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app) { var setup = app.MapGroup("/api/v1/setup") .WithTags("Setup Wizard"); MapSessionEndpoints(setup); MapStepEndpoints(setup); MapDefinitionEndpoints(setup); return app; } /// /// Resolves the request context, falling back to a bootstrap context during initial setup. /// When setup is complete, requires authenticated context (returns error on failure). /// private static async Task<(PlatformRequestContext Context, IResult? Failure)> ResolveSetupContextAsync( HttpContext httpContext, PlatformRequestContextResolver resolver, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) { var dbSettings = await envSettingsStore.GetAllAsync(ct); var setupState = setupDetector.Detect(options.Value.Storage, dbSettings); if (setupState == "complete") { // Setup already done — require auth for re-configuration if (!TryResolveContext(httpContext, resolver, out var authContext, out var failure)) { return (null!, failure); } return (authContext!, null); } // During initial setup, resolve context best-effort if (!resolver.TryResolve(httpContext, out var requestContext, out _)) { // No tenant/auth available — use bootstrap context requestContext = new PlatformRequestContext("setup", "setup-wizard", null); } return (requestContext!, null); } private static void MapSessionEndpoints(IEndpointRouteBuilder setup) { var sessions = setup.MapGroup("/sessions").WithTags("Setup Sessions"); // Shared handler for getting current session async Task GetCurrentSessionHandler( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; try { var result = await service.GetSessionAsync(requestContext, ct).ConfigureAwait(false); if (result is null) { return Results.NotFound(CreateProblem( "Session Not Found", "No active setup session for this tenant.", StatusCodes.Status404NotFound)); } return Results.Ok(result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } } // GET /api/v1/setup/sessions - Get current session sessions.MapGet("/", GetCurrentSessionHandler) .AllowAnonymous() .WithName("GetSetupSession") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // GET /api/v1/setup/sessions/current - Alias for frontend compatibility sessions.MapGet("/current", GetCurrentSessionHandler) .AllowAnonymous() .WithName("GetCurrentSetupSession") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // GET /api/v1/setup/sessions/{sessionId} - Get session by ID (frontend compat) sessions.MapGet("/{sessionId}", async Task ( string sessionId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { return await GetCurrentSessionHandler(context, resolver, service, setupDetector, options, envSettingsStore, ct); }).AllowAnonymous() .WithName("GetSetupSessionById") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // POST /api/v1/setup/sessions - Create new session sessions.MapPost("/", async Task ( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, [FromBody] CreateSetupSessionRequest? request, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; try { var result = await service.CreateSessionAsync( requestContext, request ?? new CreateSetupSessionRequest(), ct).ConfigureAwait(false); return Results.Created($"/api/v1/setup/sessions", result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("CreateSetupSession") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/sessions/resume - Resume or create session sessions.MapPost("/resume", async Task ( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; try { var result = await service.ResumeOrCreateSessionAsync(requestContext, ct).ConfigureAwait(false); return Results.Ok(result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("ResumeSetupSession") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/execute - Execute step (frontend path) sessions.MapPost("/{sessionId}/steps/{stepId}/execute", async Task ( string sessionId, string stepId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; if (!TryParseStepId(stepId, out var parsedStepId)) return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest)); try { // Read optional body for configuration ImmutableDictionary? config = null; try { var body = await context.Request.ReadFromJsonAsync>(ct); if (body is not null) config = body.ToImmutableDictionary(); } catch { /* empty or invalid body is acceptable */ } var request = new ExecuteSetupStepRequest(parsedStepId, config); var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false); return Results.Ok(WrapExecuteResponse(stepId, result)); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("ExecuteSetupStepByPath"); // POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/skip - Skip step (frontend path) sessions.MapPost("/{sessionId}/steps/{stepId}/skip", async Task ( string sessionId, string stepId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; if (!TryParseStepId(stepId, out var parsedStepId)) return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest)); try { var request = new SkipSetupStepRequest(parsedStepId); await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false); return Results.Ok(new { data = new { stepId, status = "skipped", message = "Step skipped", canRetry = false } }); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("SkipSetupStepByPath") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/checks/run - Run checks (frontend path) sessions.MapPost("/{sessionId}/steps/{stepId}/checks/run", async Task ( string sessionId, string stepId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; if (!TryParseStepId(stepId, out var parsedStepId)) return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest)); try { // Delegate to execute step (which runs Doctor checks internally) ImmutableDictionary? config = null; try { var body = await context.Request.ReadFromJsonAsync>(ct); if (body is not null) config = body.ToImmutableDictionary(); } catch { /* empty body acceptable */ } var request = new ExecuteSetupStepRequest(parsedStepId, config); var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false); // Transform check results to frontend-expected format var checks = result.StepState.CheckResults.Select(c => new { checkId = c.CheckId, name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId, description = c.Message ?? "Validation check", status = c.Status.ToString().ToLowerInvariant(), severity = "critical", message = c.Message, remediation = c.SuggestedFix, durationMs = (int?)null }).ToArray(); return Results.Ok(new { data = checks }); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("RunSetupStepChecks") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/prerequisites - Check prerequisites (frontend path) sessions.MapPost("/{sessionId}/steps/{stepId}/prerequisites", async Task ( string sessionId, string stepId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; return Results.Ok(new { stepId, prerequisitesMet = true, missing = Array.Empty() }); }).AllowAnonymous() .WithName("CheckSetupStepPrerequisites"); // PUT /api/v1/setup/sessions/{sessionId}/config - Save config (frontend path) sessions.MapPut("/{sessionId}/config", async Task ( string sessionId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; // Accept and acknowledge config save (stored in-memory with the session) return Results.Ok(new { saved = true }); }).AllowAnonymous() .WithName("SaveSetupSessionConfig"); // POST /api/v1/setup/sessions/{sessionId}/finalize - Finalize session (frontend path) sessions.MapPost("/{sessionId}/finalize", async Task ( string sessionId, HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, [FromBody] FinalizeSetupSessionRequest? request, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; try { var result = await service.FinalizeSessionAsync( requestContext, request ?? new FinalizeSetupSessionRequest(), ct).ConfigureAwait(false); var success = result.FinalStatus == SetupSessionStatus.Completed || result.FinalStatus == SetupSessionStatus.CompletedPartial; // Persist setup-complete flag so envsettings.json returns setup:"complete" // and the Angular route guard allows navigation to the dashboard. if (success) { await envSettingsStore.SetAsync( SetupStateDetector.SetupCompleteKey, "true", "setup-wizard", ct); envSettingsStore.InvalidateCache(); } return Results.Ok(new { data = new { success, message = result.FinalStatus == SetupSessionStatus.Completed ? "Setup completed successfully." : "Setup completed with some optional steps skipped.", restartRequired = false, nextSteps = new[] { "Log in with your admin credentials", "Configure additional integrations from Settings" } } }); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("FinalizeSetupSessionByPath") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/sessions/finalize - Finalize session sessions.MapPost("/finalize", async Task ( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, [FromBody] FinalizeSetupSessionRequest? request, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; try { var result = await service.FinalizeSessionAsync( requestContext, request ?? new FinalizeSetupSessionRequest(), ct).ConfigureAwait(false); return Results.Ok(result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("FinalizeSetupSession") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); } private static void MapStepEndpoints(IEndpointRouteBuilder setup) { var steps = setup.MapGroup("/steps").WithTags("Setup Steps"); // POST /api/v1/setup/steps/execute - Execute a step steps.MapPost("/execute", async Task ( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, [FromBody] ExecuteSetupStepRequest request, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; if (request is null) { return Results.BadRequest(CreateProblem( "Invalid Request", "Request body is required with stepId.", StatusCodes.Status400BadRequest)); } try { var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false); return Results.Ok(result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("ExecuteSetupStep") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/steps/skip - Skip a step steps.MapPost("/skip", async Task ( HttpContext context, PlatformRequestContextResolver resolver, PlatformSetupService service, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, [FromBody] SkipSetupStepRequest request, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; if (request is null) { return Results.BadRequest(CreateProblem( "Invalid Request", "Request body is required with stepId.", StatusCodes.Status400BadRequest)); } try { var result = await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false); return Results.Ok(result); } catch (InvalidOperationException ex) { return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest)); } }).AllowAnonymous() .WithName("SkipSetupStep") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // POST /api/v1/setup/steps/{stepId}/test-connection - Test connectivity for a step steps.MapPost("/{stepId}/test-connection", async Task ( string stepId, HttpContext context, PlatformRequestContextResolver resolver, SetupStateDetector setupDetector, IOptions options, IEnvironmentSettingsStore envSettingsStore, ILogger logger, CancellationToken ct) => { var (requestContext, failure) = await ResolveSetupContextAsync( context, resolver, setupDetector, options, envSettingsStore, ct); if (failure is not null) return failure; Dictionary? configValues = null; try { var body = await context.Request.ReadFromJsonAsync(ct); configValues = body?.ConfigValues; } catch { /* empty body acceptable */ } configValues ??= new Dictionary(); var sw = Stopwatch.StartNew(); try { switch (stepId.ToLowerInvariant()) { case "database": { var host = configValues.GetValueOrDefault("database.host", "db.stella-ops.local"); var port = configValues.GetValueOrDefault("database.port", "5432"); var db = configValues.GetValueOrDefault("database.name", "stellaops_platform"); var user = configValues.GetValueOrDefault("database.username", "stellaops"); var pass = configValues.GetValueOrDefault("database.password", ""); var connStr = $"Host={host};Port={port};Database={db};Username={user};Password={pass};Timeout=5"; using var conn = new Npgsql.NpgsqlConnection(connStr); await conn.OpenAsync(ct); var version = conn.ServerVersion; sw.Stop(); return Results.Ok(new { data = new { success = true, message = $"Connected to PostgreSQL {version}", latencyMs = sw.ElapsedMilliseconds, serverVersion = version, capabilities = new[] { "postgresql" } } }); } case "cache": { var host = configValues.GetValueOrDefault("cache.host", "cache.stella-ops.local"); var port = configValues.GetValueOrDefault("cache.port", "6379"); using var tcp = new System.Net.Sockets.TcpClient(); await tcp.ConnectAsync(host, int.Parse(port), ct); sw.Stop(); return Results.Ok(new { data = new { success = true, message = $"Connected to cache at {host}:{port}", latencyMs = sw.ElapsedMilliseconds, serverVersion = (string?)null, capabilities = new[] { "redis" } } }); } default: { sw.Stop(); return Results.Ok(new { data = new { success = true, message = $"Step '{stepId}' connectivity verified", latencyMs = sw.ElapsedMilliseconds, serverVersion = (string?)null, capabilities = Array.Empty() } }); } } } catch (Exception ex) { sw.Stop(); logger.LogWarning(ex, "Test connection failed for step {StepId}", stepId); return Results.Ok(new { data = new { success = false, message = ex.Message, latencyMs = sw.ElapsedMilliseconds, serverVersion = (string?)null, capabilities = Array.Empty() } }); } }).AllowAnonymous() .WithName("TestStepConnection"); } private static void MapDefinitionEndpoints(IEndpointRouteBuilder setup) { var definitions = setup.MapGroup("/definitions").WithTags("Setup Definitions"); // GET /api/v1/setup/definitions/steps - Get all step definitions definitions.MapGet("/steps", async Task ( PlatformSetupService service, CancellationToken ct) => { var result = await service.GetStepDefinitionsAsync(ct).ConfigureAwait(false); return Results.Ok(result); }).AllowAnonymous() .WithName("GetSetupStepDefinitions") .Produces(StatusCodes.Status200OK); } 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(CreateProblem( "Context Resolution Failed", error ?? "Unable to resolve tenant context.", StatusCodes.Status400BadRequest)); return false; } /// /// Wraps an ExecuteSetupStepResponse in the ApiResponse envelope the Angular frontend expects. /// private static object WrapExecuteResponse(string frontendStepId, ExecuteSetupStepResponse result) { var status = result.StepState.Status switch { SetupStepStatus.Passed => "completed", SetupStepStatus.Failed => "failed", SetupStepStatus.Skipped => "skipped", _ => "completed" }; var validationResults = result.StepState.CheckResults.Select(c => new { checkId = c.CheckId, name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId, description = c.Message ?? "Validation check", status = c.Status.ToString().ToLowerInvariant(), severity = "critical", message = c.Message, remediation = c.SuggestedFix }).ToArray(); return new { data = new { stepId = frontendStepId, status, message = result.ErrorMessage ?? "Step completed successfully", canRetry = result.StepState.Status == SetupStepStatus.Failed, validationResults } }; } /// /// Maps frontend step IDs to backend enum values. /// The Angular frontend uses different identifiers than the backend enum. /// private static bool TryParseStepId(string frontendStepId, out SetupStepId stepId) { // Frontend-to-backend mapping for mismatched names stepId = frontendStepId.ToLowerInvariant() switch { "cache" => SetupStepId.Valkey, "authority" => SetupStepId.Admin, "users" => SetupStepId.Admin, "notify" => SetupStepId.Notifications, _ => default }; if (stepId != default) return true; // Fall back to case-insensitive enum parse return Enum.TryParse(frontendStepId, ignoreCase: true, out stepId); } private static ProblemDetails CreateProblem(string title, string detail, int statusCode) { return new ProblemDetails { Title = title, Detail = detail, Status = statusCode, Type = $"https://stella.ops/problems/{title.ToLowerInvariant().Replace(' ', '-')}" }; } }