376 lines
13 KiB
C#
376 lines
13 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 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
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/bridge")]
|
|
[EnableRateLimiting("agents")]
|
|
public class GatewayBridgeController(
|
|
ITaskBridgeService bridge,
|
|
IAgentService agentService,
|
|
IConfiguration configuration,
|
|
ILogger<GatewayBridgeController> 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<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);
|
|
|
|
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<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
|
|
});
|
|
|
|
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 static 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)
|
|
});
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
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 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
|
|
);
|