using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Nexus.Api.Models; using Nexus.Api.Services; using System.Security.Claims; namespace Nexus.Api.Controllers; /// /// Owner-only orchestration for the single OpenClaw Attach & Adopt profile. /// The browser never receives Gateway, bootstrap or provider credentials. /// [Authorize(Roles = "owner")] [ApiController] [Route("api/v1/openclaw/setup")] public sealed class OpenClawSetupController( IOpenClawSetupService setup, IOpenClawWizardService wizard) : ControllerBase { [HttpGet] public async Task> GetStatus( CancellationToken cancellationToken) => Ok(await setup.GetStatusAsync(cancellationToken)); [HttpPost("discover")] [EnableRateLimiting("agents")] public async Task> Discover( [FromBody] OpenClawDiscoveryRequest? request, CancellationToken cancellationToken) => Ok(await setup.DiscoverAsync( request ?? new OpenClawDiscoveryRequest(), cancellationToken)); [HttpPost("probe")] [EnableRateLimiting("agents")] public async Task>> Probe( [FromBody] ProbeOpenClawRequest request, CancellationToken cancellationToken) => Map(await setup.ProbeAsync(request, cancellationToken)); [HttpPost("attach")] [EnableRateLimiting("agents")] public async Task>> Attach( [FromBody] AttachOpenClawRequest request, CancellationToken cancellationToken) => Map(await setup.AttachAsync(request, cancellationToken)); [HttpPost("verify")] [EnableRateLimiting("agents")] public async Task>> Verify( [FromBody] VerifyOpenClawRequest request, CancellationToken cancellationToken) => Map(await setup.VerifyAsync(request, cancellationToken)); [HttpPost("adopt")] [EnableRateLimiting("agents")] public async Task>> Adopt( [FromBody] AdoptOpenClawRequest request, CancellationToken cancellationToken) => Map(await setup.AdoptAsync(request, cancellationToken)); [HttpPost("management")] [EnableRateLimiting("agents")] public async Task>> SetManagement( [FromBody] SetOpenClawManagementRequest request, CancellationToken cancellationToken) => Map(await setup.SetManagementAsync(request, cancellationToken)); [HttpDelete("connection")] [EnableRateLimiting("agents")] public async Task>> DeleteConnection( [FromBody] DeleteOpenClawConnectionRequest request, CancellationToken cancellationToken) => Map(await setup.DeleteAsync(request, cancellationToken)); [HttpPost("wizard/start")] [EnableRateLimiting("agents")] public async Task> StartWizard( [FromBody] StartOpenClawWizardRequest request, CancellationToken cancellationToken) { if (!TryBuildInvocation(out var invocation, out var validationError)) return validationError!; return MapWizard(await wizard.StartAsync( request, invocation!, cancellationToken)); } [HttpPost("wizard/next")] [EnableRateLimiting("agents")] public async Task> AdvanceWizard( [FromBody] AdvanceOpenClawWizardRequest request, CancellationToken cancellationToken) { if (!TryBuildInvocation(out var invocation, out var validationError)) return validationError!; return MapWizard(await wizard.NextAsync( request, invocation!, cancellationToken)); } [HttpGet("wizard/{sessionId}")] public async Task> GetWizardStatus( string sessionId, CancellationToken cancellationToken) => MapWizard(await wizard.GetStatusAsync(sessionId, cancellationToken)); [HttpPost("wizard/{sessionId}/cancel")] [EnableRateLimiting("agents")] public async Task> CancelWizard( string sessionId, CancellationToken cancellationToken) { if (!TryBuildInvocation(out var invocation, out var validationError)) return validationError!; return MapWizard(await wizard.CancelAsync( sessionId, invocation!, cancellationToken)); } private ActionResult> Map( OpenClawSetupOperationDto result) { if (result.Ok) return Ok(result); return StatusCode(StatusFor(result.State), result); } private ActionResult MapWizard( OpenClawWizardResultDto result) { if (result.Ok) return Ok(result); var status = result.State switch { "invalid" or "confirmation_required" => StatusCodes.Status400BadRequest, "not_found" => StatusCodes.Status404NotFound, "management_disabled" or "scope_upgrade_required" => StatusCodes.Status403Forbidden, "conflict" or "server_secret_required" or "unsupported" => StatusCodes.Status409Conflict, "disconnected" or "gateway_error" => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status502BadGateway }; return StatusCode(status, result); } private bool TryBuildInvocation( out OpenClawInvocationContext? invocation, out BadRequestObjectResult? validationError) { invocation = null; validationError = null; var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim(); if (string.IsNullOrWhiteSpace(idempotencyKey) || idempotencyKey.Length > 128 || idempotencyKey.Any(char.IsControl)) { validationError = BadRequest(new ValidationProblemDetails( new Dictionary { ["Idempotency-Key"] = [ "A non-empty Idempotency-Key header with at most 128 non-control characters is required." ] })); return false; } var actor = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst(ClaimTypes.Email)?.Value ?? User.Identity?.Name ?? "authenticated-owner"; try { invocation = OpenClawInvocationContext.Create( actor, idempotencyKey, Request.Headers["X-Correlation-ID"].FirstOrDefault(), Request.Headers["traceparent"].FirstOrDefault(), includeIdempotencyParameter: false); } catch (ArgumentException exception) { validationError = BadRequest(new ValidationProblemDetails( new Dictionary { ["request"] = [exception.Message] })); return false; } Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey; Response.Headers["X-Correlation-ID"] = invocation.CorrelationId; return true; } private static int StatusFor(string state) => state switch { OpenClawSetupStates.InvalidEndpoint or OpenClawSetupStates.InvalidRequest => StatusCodes.Status400BadRequest, OpenClawSetupStates.NotFound => StatusCodes.Status404NotFound, OpenClawSetupStates.ConcurrencyConflict or OpenClawSetupStates.DynamicEndpointUnsupported or OpenClawSetupStates.ExperimentalBlocked or OpenClawSetupStates.ExcessiveScope or OpenClawSetupStates.PairingRequired or OpenClawSetupStates.ScopeUpgradeRequired => StatusCodes.Status409Conflict, OpenClawSetupStates.Disconnected or OpenClawSetupStates.GatewayUnavailable => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status502BadGateway }; }