Files
nexus/backend/Controllers/GatewayBridgeController.cs
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

423 lines
15 KiB
C#

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;
/// <summary>
/// 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 a verified JWT or X-Nexus-Api-Key. X-Agent-Id is
/// accepted only as an actor hint for a service or privileged user principal.
/// 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
/// </summary>
[ApiController]
[Route("api/bridge")]
[Authorize]
[EnableRateLimiting("agents")]
public class GatewayBridgeController(
ITaskBridgeService bridge,
IAgentService agentService,
IConfiguration configuration,
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
"Bridge endpoints require a verified JWT or X-Nexus-Api-Key.";
[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<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> 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<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> 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<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> 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<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> 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<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> 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<ActionResult<BoardResponse>> 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<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> 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<ActionResult<List<DashboardTaskDto>>> 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<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> 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<List<ActivityEntryDto>>
{
Ok = true,
Command = "get_activity",
Data = entries
});
}
[HttpGet("agent-overview")]
public async Task<ActionResult<AgentWorkflowOverview>> 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);
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
{
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
}
var agentHeader = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
HttpContext,
agentService,
configuration,
ct);
if (agentHeader.AgentId is not null)
return (true, agentHeader.AgentId, null);
if (agentHeader.HeaderProvided && !agentHeader.IsRecognized)
logger.LogWarning(
"Bridge: ignoring unknown X-Agent-Id from authenticated caller at {Ip} and continuing identity fallback",
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 forbidden = StatusCode(StatusCodes.Status403Forbidden, new { error = "Authenticated caller has no permitted bridge identity." });
logger.LogWarning("Bridge: authenticated caller has no permitted identity from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, forbidden);
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = true,
Command = command,
Data = result.Data,
Operation = BuildBridgeOperation(command, 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<T>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = true,
Command = command,
Data = result.Data is null ? null : new ActivityEntryDto(
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Operation = result.Data is null
? null
: OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto(
"activity",
result.Data.Id.ToString(),
result.Data.Type),
affectedRefs: result.Data.TaskId is { } taskId
? [new EntityRefDto("task", taskId.ToString())]
: [])
});
var statusCode = result.Outcome switch
{
TaskBridgeOutcome.NotFound => 404,
TaskBridgeOutcome.ValidationError => 400,
_ => 500
};
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private OperationResultDto? BuildBridgeOperation<T>(string command, T? data)
where T : class
{
if (command.StartsWith("get_", StringComparison.Ordinal) || data is null)
return null;
if (data is DashboardTaskDto task)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
return null;
}
}
public sealed class TaskBridgeCommandResponse<T>
{
public bool Ok { get; init; }
public string Command { get; init; } = string.Empty;
public T? Data { get; init; }
public string? Error { get; init; }
public OperationResultDto? Operation { 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
);