using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Nexus.Api.DTOs; using Nexus.Api.Models; using Nexus.Api.Services; namespace Nexus.Api.Controllers; /// /// MCP-style (structured-command) backend bridge for agent-facing operations. /// /// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with /// the Nexus task board, activity log, and delegation workflow. /// /// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id. /// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend. /// /// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway /// calls these structured HTTP endpoints (same pattern, simpler transport). /// /// COMMANDS: /// create_task → POST /api/bridge/tasks /// create_child_task → POST /api/bridge/tasks/{id}/children /// update_status → PATCH /api/bridge/tasks/{id}/status /// append_activity → POST /api/bridge/tasks/{id}/activity /// handoff → POST /api/bridge/tasks/{id}/handoff /// get_board → GET /api/bridge/board /// get_task → GET /api/bridge/tasks/{id} /// get_children → GET /api/bridge/tasks/{id}/children /// get_activity → GET /api/bridge/tasks/{id}/activity /// get_agent_overview → GET /api/bridge/agent-overview /// [ApiController] [Route("api/bridge")] [EnableRateLimiting("agents")] public class GatewayBridgeController( ITaskBridgeService bridge, IAgentService agentService, IConfiguration configuration, ILogger logger) : ControllerBase { private const string ApikeyErrorMessage = "Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity."; [HttpGet("health")] public IResult Health() { return Results.Ok(new { status = "ok", service = "nexus-bridge", version = "1.0.0", commands = new[] { "create_task", "create_child_task", "update_status", "append_activity", "handoff", "get_board", "get_task", "get_children", "get_activity", "get_agent_overview" } }); } [HttpPost("tasks")] public async Task>> CreateTask( [FromBody] BridgeCreateTaskCommand command, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var agentId = resolution.AgentId; var result = await bridge.CreateTaskAsync( title: command.Title, detail: command.Detail, source: ResolveSource(agentId), priority: command.Priority ?? "Normal", assignedTo: command.AssignedTo ?? agentId, projectId: command.ProjectId, ct: ct); return MapResult(result, "create_task"); } [HttpPost("tasks/{parentTaskId:guid}/children")] public async Task>> CreateChildTask( Guid parentTaskId, [FromBody] BridgeCreateChildTaskCommand command, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var agentId = resolution.AgentId; var result = await bridge.CreateChildTaskAsync( parentTaskId: parentTaskId, title: command.Title, detail: command.Detail, source: ResolveSource(agentId), priority: command.Priority ?? "Normal", assignedTo: command.AssignedTo, expectedFrom: command.ExpectedFrom ?? command.AssignedTo, startsInProgress: command.StartsInProgress, ct: ct); return MapResult(result, "create_child_task"); } [HttpPatch("tasks/{taskId:guid}/status")] public async Task>> UpdateStatus( Guid taskId, [FromBody] BridgeUpdateStatusCommand command, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var agentId = resolution.AgentId; var result = await bridge.UpdateStatusAsync( taskId: taskId, state: command.State, callerAgent: agentId, ct: ct); return MapResult(result, "update_status"); } [HttpPost("tasks/{taskId:guid}/activity")] public async Task>> AppendActivity( Guid taskId, [FromBody] BridgeAppendActivityCommand command, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var result = await bridge.AppendActivityAsync( taskId: taskId, message: command.Message, type: command.Type ?? "comment", ct: ct); return MapActivityResult(result, "append_activity"); } [HttpPost("tasks/{taskId:guid}/handoff")] public async Task>> Handoff( Guid taskId, [FromBody] BridgeHandoffCommand command, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var result = await bridge.HandoffAsync( taskId: taskId, targetAgent: command.TargetAgent, note: command.Note, ct: ct); return MapResult(result, "handoff"); } [HttpGet("board")] public async Task> GetBoard(CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; return Ok(await bridge.GetBoardAsync(ct)); } [HttpGet("tasks/{taskId:guid}")] public async Task>> GetTask( Guid taskId, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var result = await bridge.GetTaskAsync(taskId, ct); return MapResult(result, "get_task"); } [HttpGet("tasks/{taskId:guid}/children")] public async Task>> GetChildren( Guid taskId, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; return Ok(await bridge.GetChildTasksAsync(taskId, ct)); } [HttpGet("tasks/{taskId:guid}/activity")] public async Task>>> GetActivity( Guid taskId, CancellationToken ct) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var events = await bridge.GetTaskActivityAsync(taskId, ct); var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList(); return Ok(new TaskBridgeCommandResponse> { Ok = true, Command = "get_activity", Data = entries }); } [HttpGet("agent-overview")] public async Task> GetAgentOverview( CancellationToken ct, [FromQuery] int staleHours = 2) { var resolution = await TryResolveAgentAsync(ct); if (!resolution.Success) return resolution.ErrorResult!; var threshold = TimeSpan.FromHours(Math.Max(1, staleHours)); return Ok(await bridge.GetAgentOverviewAsync(threshold, ct)); } private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct) { var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct); var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds); var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(agentHeader)) { var normalizedHeader = agentHeader.Trim().ToLowerInvariant(); if (allowedActorIds.Contains(normalizedHeader)) return (true, normalizedHeader, null); logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback", normalizedHeader, HttpContext.Connection.RemoteIpAddress); } if (User.Identity?.IsAuthenticated == true) { var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant(); if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim)) return (true, normalizedClaim, null); // Browser JWT fallback is intentionally restricted to board owners/admins. // Agent/service traffic should authenticate as an allowed agent or service principal. if (User.IsInRole("owner") || User.IsInRole("admin")) return (true, "bao", null); } if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) && allowedActorIds.Contains("nexus-system")) return (true, "nexus-system", null); var unauthorized = Unauthorized(new { error = ApikeyErrorMessage }); logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress); return (false, string.Empty, unauthorized); } private static string ResolveSource(string agentId) => agentId switch { "bao" or "nexus-system" => "bao", _ => agentId }; private static ActionResult MapResult(TaskBridgeResult result, string command) where T : class { if (result.Outcome == TaskBridgeOutcome.Success) return new OkObjectResult(new TaskBridgeCommandResponse { Ok = true, Command = command, Data = result.Data }); var statusCode = result.Outcome switch { TaskBridgeOutcome.NotFound => 404, TaskBridgeOutcome.InvalidState => 422, TaskBridgeOutcome.Unauthorized => 403, TaskBridgeOutcome.ValidationError => 400, _ => 500 }; return new ObjectResult(new TaskBridgeCommandResponse { Ok = false, Command = command, Error = result.Error ?? "Unknown error" }) { StatusCode = statusCode }; } private static ActionResult MapActivityResult(TaskBridgeResult result, string command) { if (result.Outcome == TaskBridgeOutcome.Success) return new OkObjectResult(new TaskBridgeCommandResponse { Ok = true, Command = command, Data = result.Data is null ? null : new ActivityEntryDto( result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt) }); var statusCode = result.Outcome switch { TaskBridgeOutcome.NotFound => 404, TaskBridgeOutcome.ValidationError => 400, _ => 500 }; return new ObjectResult(new TaskBridgeCommandResponse { Ok = false, Command = command, Error = result.Error ?? "Unknown error" }) { StatusCode = statusCode }; } } public sealed class TaskBridgeCommandResponse { public bool Ok { get; init; } public string Command { get; init; } = string.Empty; public T? Data { get; init; } public string? Error { get; init; } public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o"); } public sealed record BridgeCreateTaskCommand( string Title, string? Detail = null, string? Priority = null, string? AssignedTo = null, Guid? ProjectId = null ); public sealed record BridgeCreateChildTaskCommand( string Title, string? Detail = null, string? Priority = null, string? AssignedTo = null, string? ExpectedFrom = null, bool StartsInProgress = false ); public sealed record BridgeUpdateStatusCommand(string State); public sealed record BridgeAppendActivityCommand( string Message, string? Type = null ); public sealed record BridgeHandoffCommand( string TargetAgent, string? Note = null ); public sealed record ActivityEntryDto( long Id, string Type, string Message, DateTimeOffset CreatedAt );